From 337f2871e1d617687cbca417f4c92b384ecf976c Mon Sep 17 00:00:00 2001 From: Dan Oved Date: Fri, 22 Nov 2024 04:43:57 +0900 Subject: [PATCH] Comments Anytime Protocol (#735) # Comments Anytime Protocol ## Overview This PR introduces a new social layer to Zora's protocol, enabling comments and value-based interactions ("sparks") on 1155 tokens. ## Key Features - **Token-gated commenting system**: Allows token owners and holders to comment on 1155 tokens - **Value-based interactions**: - Token holders pay 1 Spark to comment - Creators can comment for free - Anyone can "spark" (like with value) existing comments - **Revenue sharing**: - Comment Sparks go to token creators - Reply Sparks go to original commenters - 30% protocol fee with 20% available for referrers - **Cross-chain functionality** through permit-based commenting and sparking - **Smart wallet support** for commenting as a smart wallet owner - **Legacy comment support** through backfilling system ## Contracts Added - `Comments.sol` (`0x7777777bE14a1F7Fd6896B5FBDa5ceD5FC6e501a`) - Core upgradeable contract for comment functionality - Deterministically deployed across all chains - `CallerAndCommenter.sol` (`0x77777775C5074b74540d9cC63Dd840A8c692B4B5`) - Helper contract for combined mint + comment operations - Enables free comments during minting ## Technical Notes - Implements unique comment identification system using nonces - Includes comprehensive event system for tracking comments, sparks, and backfills - Built-in referral system for third-party integrations - Cross-chain compatibility through permit-based operations ## Security Considerations - Token ownership verification for commenting privileges - Value distribution system for Spark payments - Smart wallet verification system --- .github/workflows/contracts.yml | 10 + docs/package.json | 1 + .../pages/changelogs/protocol-deployments.mdx | 12 + docs/pages/changelogs/protocol-sdk.mdx | 9 + docs/pages/contracts/Comments.mdx | 461 +++++ docs/snippets/contracts/comments/comment.ts | 69 + .../comments/commentWithSmartWallet.ts | 87 + docs/snippets/contracts/comments/config.ts | 52 + .../contracts/comments/mintAndComment.ts | 40 + .../contracts/comments/permitComment.ts | 87 + .../comments/permitMintAndComment.ts | 95 ++ .../contracts/comments/permitSparkComment.ts | 82 + docs/snippets/contracts/comments/referrer.ts | 47 + docs/snippets/contracts/comments/reply.ts | 54 + docs/snippets/contracts/comments/sparking.ts | 43 + .../collect/buy1155OnSecondary.ts | 2 + docs/vocs.config.ts | 4 + packages/1155-deployments/foundry.toml | 2 + .../1155-deployments/src/DeploymentConfig.sol | 10 + .../test/UpgradesTestBase.sol | 118 +- packages/comments/.env.example | 11 + packages/comments/.gitignore | 18 + packages/comments/README.md | 41 + packages/comments/_imagine/Enjoy.sol | 41 + packages/comments/addresses/1.json | 9 + packages/comments/addresses/10.json | 9 + packages/comments/addresses/11155111.json | 9 + packages/comments/addresses/11155420.json | 9 + packages/comments/addresses/42161.json | 9 + packages/comments/addresses/7777777.json | 9 + packages/comments/addresses/81457.json | 9 + packages/comments/addresses/8453.json | 9 + packages/comments/addresses/84532.json | 9 + packages/comments/addresses/999999999.json | 9 + .../callerAndCommenter.json | 8 + .../deterministicConfig/comments.json | 8 + .../deployerAndCaller.json | 1 + packages/comments/foundry.toml | 24 + packages/comments/package.json | 63 + packages/comments/package/index.ts | 4 + packages/comments/package/types.ts | 8 + packages/comments/remappings.txt | 8 + .../script/AddDelegateCommenterRole.s.sol | 24 + .../comments/script/CommentsDeployerBase.sol | 143 ++ packages/comments/script/Deploy.s.sol | 24 + .../script/DeployCallerAndCommenterImpl.s.sol | 29 + packages/comments/script/DeployImpl.s.sol | 27 + .../script/DeployNonDeterministic.s.sol | 52 + .../script/GenerateDeterministicParams.s.sol | 83 + packages/comments/script/bundle-abis.ts | 109 ++ packages/comments/script/storage-check.sh | 57 + .../script/update-contract-version.ts | 63 + packages/comments/scripts/abis.ts | 3 + .../scripts/generateCommentsTestData.ts | 338 ++++ .../comments/scripts/getCommentsAddresses.ts | 10 + .../comments/scripts/signDeployAndCall.ts | 51 + packages/comments/scripts/turnkey.ts | 36 + packages/comments/scripts/utils.ts | 127 ++ packages/comments/slither.config.json | 7 + packages/comments/src/CommentsImpl.sol | 685 ++++++++ .../comments/src/CommentsImplConstants.sol | 44 + .../src/interfaces/ICallerAndCommenter.sol | 215 +++ .../comments/src/interfaces/IComments.sol | 303 ++++ .../comments/src/interfaces/IMultiOwnable.sol | 10 + .../src/interfaces/ISecondarySwap.sol | 40 + .../src/interfaces/IZoraCreator1155.sol | 17 + .../interfaces/IZoraCreator1155TypesV1.sol | 46 + .../src/interfaces/IZoraTimedSaleStrategy.sol | 25 + .../comments/src/proxy/CallerAndCommenter.sol | 43 + packages/comments/src/proxy/Comments.sol | 43 + .../src/utils/CallerAndCommenterImpl.sol | 376 +++++ .../utils/EIP712UpgradeableWithChainId.sol | 25 + .../src/version/ContractVersionBase.sol | 14 + .../test/CallerAndCommenterTestBase.sol | 77 + ...llerAndCommenter_mintAndComment.t copy.sol | 214 +++ .../CallerAndCommenter_swapAndComment.t.sol | 523 ++++++ packages/comments/test/Comments.t.sol | 619 +++++++ packages/comments/test/CommentsTestBase.sol | 78 + .../test/Comments_delegateComment.t.sol | 129 ++ packages/comments/test/Comments_permit.t.sol | 484 ++++++ .../comments/test/Comments_smartWallet.t.sol | 152 ++ packages/comments/test/mocks/Mock1155.sol | 61 + .../Mock1155NoCreatorRewardRecipient.sol | 65 + .../comments/test/mocks/Mock1155NoOwner.sol | 53 + .../test/mocks/MockDelegateCommenter.sol | 36 + .../test/mocks/MockIZoraCreator1155.sol | 16 + .../comments/test/mocks/MockSecondarySwap.sol | 30 + .../comments/test/mocks/MockZoraTimedSale.sol | 38 + .../comments/test/mocks/ProtocolRewards.sol | 1497 +++++++++++++++++ packages/comments/tsconfig.build.json | 10 + packages/comments/tsconfig.json | 9 + packages/comments/tsup.config.ts | 11 + packages/comments/wagmi.config.ts | 16 + packages/creator-subgraph/CHANGELOG.md | 12 + .../creator-subgraph/config/zora-mainnet.yaml | 8 +- .../creator-subgraph/config/zora-sepolia.yaml | 8 +- packages/creator-subgraph/package.json | 5 +- packages/creator-subgraph/schema.graphql | 23 + .../creator-subgraph/scripts/extract-abis.mjs | 3 + .../templates/commentHandlers.ts | 126 ++ .../creator-subgraph/subgraph.template.yaml | 26 + .../protocol-deployments-gen/package.json | 1 + .../protocol-deployments-gen/wagmi.config.ts | 50 + packages/protocol-deployments/CHANGELOG.md | 12 + packages/protocol-deployments/package.json | 2 +- .../protocol-deployments/src/typedData.ts | 262 +++ packages/protocol-deployments/src/types.ts | 68 + packages/protocol-rewards/CHANGELOG.md | 6 + packages/protocol-rewards/package.json | 2 +- .../src/interfaces/IProtocolRewards.sol | 4 + packages/protocol-sdk/CHANGELOG.md | 9 + packages/protocol-sdk/package.json | 8 +- .../src/comments/comments.test.ts | 338 ++++ .../src/create/1155-create-helper.test.ts | 14 +- .../src/create/1155-create-helper.ts | 2 + .../src/create/mint-from-create.ts | 3 + .../protocol-sdk/src/mint/mint-client.test.ts | 97 +- packages/protocol-sdk/src/mint/mint-client.ts | 11 +- .../protocol-sdk/src/mint/mint-queries.ts | 21 +- .../src/mint/mint-transactions.ts | 96 +- packages/protocol-sdk/src/sdk.ts | 1 + .../src/secondary/secondary-client.test.ts | 250 ++- .../src/secondary/secondary-client.ts | 142 +- packages/protocol-sdk/src/secondary/types.ts | 2 + .../sparks-sponsored-sparks-spender.test.ts | 1 + packages/protocol-sdk/src/test-utils.ts | 19 + .../chainConfigs/11155420.json | 7 + .../deployerAndCaller.json | 5 + packages/shared-contracts/package.json | 14 +- packages/shared-contracts/remappings.txt | 3 +- .../src/deployment/ProxyDeployerScript.sol | 43 +- .../src/interfaces/IContractMetadata.sol | 2 +- .../src/interfaces/IVersionedContract.sol | 2 +- .../src/upgrades/UpgradeBaseLib.sol | 18 +- .../src/utils/UnorderedNoncesUpgradeable.sol | 44 + pnpm-lock.yaml | 545 ++---- 136 files changed, 10497 insertions(+), 615 deletions(-) create mode 100644 docs/pages/contracts/Comments.mdx create mode 100644 docs/snippets/contracts/comments/comment.ts create mode 100644 docs/snippets/contracts/comments/commentWithSmartWallet.ts create mode 100644 docs/snippets/contracts/comments/config.ts create mode 100644 docs/snippets/contracts/comments/mintAndComment.ts create mode 100644 docs/snippets/contracts/comments/permitComment.ts create mode 100644 docs/snippets/contracts/comments/permitMintAndComment.ts create mode 100644 docs/snippets/contracts/comments/permitSparkComment.ts create mode 100644 docs/snippets/contracts/comments/referrer.ts create mode 100644 docs/snippets/contracts/comments/reply.ts create mode 100644 docs/snippets/contracts/comments/sparking.ts create mode 100644 packages/comments/.env.example create mode 100644 packages/comments/.gitignore create mode 100644 packages/comments/README.md create mode 100644 packages/comments/_imagine/Enjoy.sol create mode 100644 packages/comments/addresses/1.json create mode 100644 packages/comments/addresses/10.json create mode 100644 packages/comments/addresses/11155111.json create mode 100644 packages/comments/addresses/11155420.json create mode 100644 packages/comments/addresses/42161.json create mode 100644 packages/comments/addresses/7777777.json create mode 100644 packages/comments/addresses/81457.json create mode 100644 packages/comments/addresses/8453.json create mode 100644 packages/comments/addresses/84532.json create mode 100644 packages/comments/addresses/999999999.json create mode 100644 packages/comments/deterministicConfig/callerAndCommenter.json create mode 100644 packages/comments/deterministicConfig/comments.json create mode 120000 packages/comments/deterministicConfig/deployerAndCaller.json create mode 100644 packages/comments/foundry.toml create mode 100644 packages/comments/package.json create mode 100644 packages/comments/package/index.ts create mode 100644 packages/comments/package/types.ts create mode 100644 packages/comments/remappings.txt create mode 100644 packages/comments/script/AddDelegateCommenterRole.s.sol create mode 100644 packages/comments/script/CommentsDeployerBase.sol create mode 100644 packages/comments/script/Deploy.s.sol create mode 100644 packages/comments/script/DeployCallerAndCommenterImpl.s.sol create mode 100644 packages/comments/script/DeployImpl.s.sol create mode 100644 packages/comments/script/DeployNonDeterministic.s.sol create mode 100644 packages/comments/script/GenerateDeterministicParams.s.sol create mode 100644 packages/comments/script/bundle-abis.ts create mode 100755 packages/comments/script/storage-check.sh create mode 100644 packages/comments/script/update-contract-version.ts create mode 100644 packages/comments/scripts/abis.ts create mode 100644 packages/comments/scripts/generateCommentsTestData.ts create mode 100644 packages/comments/scripts/getCommentsAddresses.ts create mode 100644 packages/comments/scripts/signDeployAndCall.ts create mode 100644 packages/comments/scripts/turnkey.ts create mode 100644 packages/comments/scripts/utils.ts create mode 100644 packages/comments/slither.config.json create mode 100644 packages/comments/src/CommentsImpl.sol create mode 100644 packages/comments/src/CommentsImplConstants.sol create mode 100644 packages/comments/src/interfaces/ICallerAndCommenter.sol create mode 100644 packages/comments/src/interfaces/IComments.sol create mode 100644 packages/comments/src/interfaces/IMultiOwnable.sol create mode 100644 packages/comments/src/interfaces/ISecondarySwap.sol create mode 100644 packages/comments/src/interfaces/IZoraCreator1155.sol create mode 100644 packages/comments/src/interfaces/IZoraCreator1155TypesV1.sol create mode 100644 packages/comments/src/interfaces/IZoraTimedSaleStrategy.sol create mode 100644 packages/comments/src/proxy/CallerAndCommenter.sol create mode 100644 packages/comments/src/proxy/Comments.sol create mode 100644 packages/comments/src/utils/CallerAndCommenterImpl.sol create mode 100644 packages/comments/src/utils/EIP712UpgradeableWithChainId.sol create mode 100644 packages/comments/src/version/ContractVersionBase.sol create mode 100644 packages/comments/test/CallerAndCommenterTestBase.sol create mode 100644 packages/comments/test/CallerAndCommenter_mintAndComment.t copy.sol create mode 100644 packages/comments/test/CallerAndCommenter_swapAndComment.t.sol create mode 100644 packages/comments/test/Comments.t.sol create mode 100644 packages/comments/test/CommentsTestBase.sol create mode 100644 packages/comments/test/Comments_delegateComment.t.sol create mode 100644 packages/comments/test/Comments_permit.t.sol create mode 100644 packages/comments/test/Comments_smartWallet.t.sol create mode 100644 packages/comments/test/mocks/Mock1155.sol create mode 100644 packages/comments/test/mocks/Mock1155NoCreatorRewardRecipient.sol create mode 100644 packages/comments/test/mocks/Mock1155NoOwner.sol create mode 100644 packages/comments/test/mocks/MockDelegateCommenter.sol create mode 100644 packages/comments/test/mocks/MockIZoraCreator1155.sol create mode 100644 packages/comments/test/mocks/MockSecondarySwap.sol create mode 100644 packages/comments/test/mocks/MockZoraTimedSale.sol create mode 100644 packages/comments/test/mocks/ProtocolRewards.sol create mode 100644 packages/comments/tsconfig.build.json create mode 100644 packages/comments/tsconfig.json create mode 100644 packages/comments/tsup.config.ts create mode 100644 packages/comments/wagmi.config.ts create mode 100644 packages/creator-subgraph/src/commentsMappings/templates/commentHandlers.ts create mode 100644 packages/protocol-sdk/src/comments/comments.test.ts create mode 100644 packages/shared-contracts/chainConfigs/11155420.json create mode 100644 packages/shared-contracts/deterministicConfig/deployerAndCaller.json create mode 100644 packages/shared-contracts/src/utils/UnorderedNoncesUpgradeable.sol diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml index ec954c7ed..26dbf9cbd 100644 --- a/.github/workflows/contracts.yml +++ b/.github/workflows/contracts.yml @@ -50,3 +50,13 @@ jobs: with: package_folder: packages/1155-deployments precache: "pnpm run generate" + + contracts-comments: + name: Comments + uses: ./.github/workflows/contract.yml + secrets: inherit + with: + package_folder: packages/comments + name: Comments + ignore_coverage_files: '"*lib*" "*mock*"' + skip_storage_layout: true diff --git a/docs/package.json b/docs/package.json index 7fd5545d2..ad6beac56 100644 --- a/docs/package.json +++ b/docs/package.json @@ -32,6 +32,7 @@ "yaml": "2.3.4" }, "devDependencies": { + "@reservoir0x/relay-sdk": "^1.3.3", "@zoralabs/tsconfig": "workspace:*" } } diff --git a/docs/pages/changelogs/protocol-deployments.mdx b/docs/pages/changelogs/protocol-deployments.mdx index 3b170dd76..135513317 100644 --- a/docs/pages/changelogs/protocol-deployments.mdx +++ b/docs/pages/changelogs/protocol-deployments.mdx @@ -1,6 +1,18 @@ # @zoralabs/protocol-deployments Changelog +## 0.3.9 + +### Patch Changes + +- [4928687d](https://github.com/ourzora/zora-protocol/commit/4928687d): - Include the `Comments` and `CallerAndCommenter` abis and deployed addresses. + - Added new exports for Comments contract cross-chain functionality: + - Introduced `permitCommentTypedDataDefinition` function to generate typed data for cross-chain permit commenting + - Introduced `permitSparkCommentTypedDataDefinition` function to generate typed data for cross-chain permit sparking + - Introduced `permitTimedSaleMintAndCommentTypedDataType` to generate typed data for cross-chain permit minting and commenting. + - Introduced `permitBuyOnSecondaryAndCommentTypedDataDefinition` function to generate typed data for cross-chain permit buying on secondary and commenting. + - Added `sparkValue` helper function to get the value of a Spark + ## 0.3.8 ### Patch Changes diff --git a/docs/pages/changelogs/protocol-sdk.mdx b/docs/pages/changelogs/protocol-sdk.mdx index 137350dce..ea6b670c8 100644 --- a/docs/pages/changelogs/protocol-sdk.mdx +++ b/docs/pages/changelogs/protocol-sdk.mdx @@ -1,6 +1,15 @@ # @zoralabs/protocol-sdk Changelog +## 0.11.9 + +### Patch Changes + +- [9d5d1638](https://github.com/ourzora/zora-protocol/commit/9d5d1638): When minting + commenting, and using the timed sale strategy, protocol sdk will call the CallerAndCommenter contract +- [088ec6fb](https://github.com/ourzora/zora-protocol/commit/088ec6fb): When buying on secondary, you can now add a comment, which will call the CallerAndCommenter's buyOnSecondaryAndComment function. +- Updated dependencies [4928687d](https://github.com/ourzora/zora-protocol/commit/4928687d) + - @zoralabs/protocol-deployments@0.3.9 + ## 0.11.8 ### Patch Changes diff --git a/docs/pages/contracts/Comments.mdx b/docs/pages/contracts/Comments.mdx new file mode 100644 index 000000000..0ea33cbc2 --- /dev/null +++ b/docs/pages/contracts/Comments.mdx @@ -0,0 +1,461 @@ +# Comments + +The Comments contract allows for comments to be made on any Zora 1155 token. Only 1155 token owners or holders can comment on that token. +If the commenter is an owner, they must pay a Spark to comment. If the commenter is a creator, they can comment for free. + +Comments can be Sparked by anyone, meaning that the Sparker must send a Spark as a form of liking a comment. + +## Contracts + +The protocol consists of a single upgradeable contract called `Comments`, that is deployed deterministically to the same address on all chains. + +| Contract | Deterministic Address | +| ----- | ------- | +| Comments | 0x7777777bE14a1F7Fd6896B5FBDa5ceD5FC6e501a | + +## Spark value distribution + +When a commenter pays a Spark to comment on a token, the Spark value (less a protocol fee) is sent to the token's creator reward recipient. +When a commenter pays a Spark to reply to a comment, the Spark value (less a protocol fee) is sent to the original commenter as a reward. +When a Spark is used to Spark a comment, the Spark value (less a protocol fee) is sent to the commenter. + +For each Spark value transaction, a 30% protocol fee is taken. Part of that fee can be paid out to a referrer (if there is one). A referrer can be a third-party developer that surfaces the ability to comment on a site or app, +and the referrer address is specified as an argument when commenting or Sparking. + +## Building on comments and earning referral rewards + +Developers can integrate the Comments contract into on their platform from day one and earn referral rewards when users when users spark a comment on their platform. + +When a referral address is specified when minting or sparking, 20% of the total Spark value is paid out to the referrer. + +To earn referral rewards, developers should [specify a referrer address in the function calls](#specifying-a-referrer) + +## What is a Spark? + +[A Spark is a fundamental concept in the Zora ecosystem.](https://support.zora.co/en/articles/1829633) +It serves as a unit of value and can be used to pay for mints and other interactions: + +- Sparks are [1155 tokens on the Zora network.](https://explorer.zora.energy/address/0x7777777b3eA6C126942BB14dD5C3C11D365C385D) They can be purchased with credit/debit cards or ETH, primarily used to cover minting fees for NFTs on Zora. +- **Each Spark has an immutable value of 0.000001 ETH** +- In the context of the Comments contract, Sparks are used to pay for comments (for non-creators) and to "like" or endorse comments made by others. +- Sparks can be unwrapped by their owner, allowing the underlying ETH value to be used for other transactions. + +## Backfilled legacy comments + +Before the Comments contract's deployment, comments were made on other contracts that emitted `MintComment` events. To enable users to reply to or Spark these older comments, we backfill the new Comments contract with legacy comment data. This process: + +1. Saves onchain unique IDs for the legacy comments. +2. Allows users to interact with pre-existing comments since they have an onchain ID. + +## Usage + +### Commenting + +Commenting can be done by calling the `comment` function, paying with the equivalent value in Sparks: +```solidity +interface IComments { + struct CommentIdentifier { + address commenter; + address contractAddress; + uint256 tokenId; + bytes32 nonce; + } + + /// @notice Creates a new comment. Equivalent Sparks value in ETH must be sent with the transaction. Must be a holder or creator of the referenced 1155 token. + /// If not the owner, must send at least 1 Spark. Sparks are transferred from the commenter to the Sparks recipient (either the creator when there is no replyTo, or the replyTo commenter). + /// @param contractAddress The address of the contract + /// @param tokenId The token ID + /// @param commenter The address of the commenter + /// @param text The text content of the comment + /// @param replyTo The identifier of the comment being replied to (if any) + /// @return commentIdentifier The identifier of the created comment, including the nonce + function comment( + address commenter, + address contractAddress, + uint256 tokenId, + string calldata text, + CommentIdentifier calldata replyTo, + address referrer + ) external payable returns (CommentIdentifier memory commentIdentifier) { + } +} +``` + +Example usage with `@zoralabs/protocol-deployments` and `viem`: + +:::code-group + +```ts twoslash [example.ts] +// @filename: config.ts +// [!include ~/snippets/contracts/comments/config.ts] + +// @filename: example.ts +// ---cut--- +// [!include ~/snippets/contracts/comments/comment.ts] +``` + +```ts twoslash [config.ts] +// [!include ~/snippets/contracts/comments/config.ts] +``` + +::: + +Note: The `getSparksValue` function is used to calculate the equivalent ETH value for a given number of sparks. It's implementation is not shown here but is crucial for determining the correct payment amount. + +### Replying to a comment + +When a comment is created, it is associated with a unique identifier. This identifier is used to reply to the comment. +The unique identifier contains an autoincrementing nonce generated by the contract that is used to ensure that the identifier is unique +for a given commenter, contract, and tokenId. + +When replying to a comment, the replyTo argument is the identifier of the comment being replied to. + +```solidity +interface IComments { + struct CommentIdentifier { + address commenter; + address contractAddress; + uint256 tokenId; + bytes32 nonce; + } + + function comment( + address commenter, + address contractAddress, + uint256 tokenId, + string calldata text, + // this identifies the comment that we are replying to + CommentIdentifier calldata replyTo, // [!code focus] + address referrer + ) external payable returns (CommentIdentifier memory commentIdentifier) { + } +} +``` + +Example usage with `@zoralabs/protocol-deployments` and `viem`: + +:::code-group + +```ts twoslash [example.ts] +// @filename: config.ts +// [!include ~/snippets/contracts/comments/config.ts] + +// @filename: comment.ts +// [!include ~/snippets/contracts/comments/comment.ts] + +// @filename: example.ts +// ---cut--- +// [!include ~/snippets/contracts/comments/reply.ts] +``` + +```ts twoslash [comment.ts] +// @filename: config.ts +// [!include ~/snippets/contracts/comments/config.ts] + +// @filename: comment.ts +// ---cut--- +// [!include ~/snippets/contracts/comments/comment.ts] +``` + +```ts twoslash [config.ts] +// [!include ~/snippets/contracts/comments/config.ts] +``` +::: + +### Sparking a comment + +Sparking a comment is done by calling the `sparkComment` function, paying with the equivalent value in Sparks. +Sparking a comment is similar to liking a comment, except it is liked with the value of Sparks attached. The Spark value gets sent to the commenter, with a fee taken out. + +```solidity +interface IComments { + struct CommentIdentifier { + address commenter; + address contractAddress; + uint256 tokenId; + // nonce is a unique value that is generated when a comment is created. It is used to ensure that the comment identifier is unique + // for a given commenter, contract, and tokenId. + bytes32 nonce; + } + + /// @notice Sparks a comment. Equivalent Sparks value in ETH to sparksQuantity must be sent with the transaction. Sparking a comment is + /// similar to liking it, except it is liked with the value of Sparks attached. The Spark value gets sent to the commenter, with a fee taken out. + /// @param commentIdentifier The identifier of the comment to Spark + /// @param sparksQuantity The quantity of Sparks to send + /// @param referrer The referrer of the comment + function sparkComment(CommentIdentifier calldata commentIdentifier, uint64 sparksQuantity, address referrer) public payable; +} +``` + +Example usage with `@zoralabs/protocol-deployments` and `viem`: + +:::code-group + +```ts twoslash [example.ts] +// @filename: config.ts +// [!include ~/snippets/contracts/comments/config.ts] + +// @filename: example.ts +// ---cut--- +// [!include ~/snippets/contracts/comments/sparking.ts] +``` + +```ts twoslash [config.ts] +// [!include ~/snippets/contracts/comments/config.ts] +``` + +::: + +### Minting and commenting + +When minting with the `ZoraTimedSaleStrategy`, which is the default way to mint on Zora, +a comment can be included at no additional cost by calling the function `timedSaleMintAndComment()` on the `CallerAndCommenter` helper contract. While the comment itself is free, the standard mint fee still needs to be sent with the transaction. + +```solidity +// Deployed to 0x77777775C5074b74540d9cC63Dd840A8c692B4B5 on all chains supported by Zora. +interface ICallerAndCommenter { + /// @notice Mints tokens and adds a comment, without needing to pay a spark for the comment. + /// @dev The payable amount should be the total mint fee. No spark value should be sent. + /// @param commenter The address of the commenter + /// @param quantity The number of tokens to mint + /// @param collection The address of the 1155 collection to mint from + /// @param tokenId The 1155 token Id to mint + /// @param mintReferral The address to receive mint referral rewards, if any + /// @param comment The comment to be added. If empty, no comment will be added. + /// @return The identifier of the newly created comment + function timedSaleMintAndComment( + address commenter, + uint256 quantity, + address collection, + uint256 tokenId, + address mintReferral, + string calldata comment + ) external payable returns (IComments.CommentIdentifier memory); +} +``` + +Example usage with `@zoralabs/protocol-deployments` and `viem`: + +:::code-group + +```ts twoslash [example.ts] +// @filename: config.ts +// [!include ~/snippets/contracts/comments/config.ts] + +// @filename: example.ts +// ---cut--- +// [!include ~/snippets/contracts/comments/mintAndComment.ts] +``` + +```ts twoslash [config.ts] +// [!include ~/snippets/contracts/comments/config.ts] +``` +::: + +### Specifying a Referrer + +When calling the `comment`, `sparkComment`, or related functions, a referrer address can be specified. This allows third-party developers to earn a portion of the protocol fee when users interact with the Comments contract through on their platform. + +To specify a referrer, simply include the referrer's address as the last argument in the function call: + +:::code-group +```ts twoslash [example.ts] +// @filename: config.ts +// [!include ~/snippets/contracts/comments/config.ts] + +// @filename: example.ts +// ---cut--- +// [!include ~/snippets/contracts/comments/referrer.ts] +``` + +```ts twoslash [config.ts] +// [!include ~/snippets/contracts/comments/config.ts] +``` +::: + +### Commenting as a smart wallet owner + +An account that is a smart wallet owner can comment on a token, if a smart wallet +is an owner or creator of the token. In this case, the smart wallet address should +be passed as the `smartWallet` argument when calling the `comment` function. The function +will check if the smart wallet or the account that is creating the comment is an owner or creator of the token, +but will attribute the comment to the account that is calling the comment function. Correspondingly, +the `commenter` argument must match the account that is creating the comment. + +Example usage with `@zoralabs/protocol-deployments` and `viem`: + +:::code-group + +```ts twoslash [example.ts] +// @filename: config.ts +// [!include ~/snippets/contracts/comments/config.ts] + +// @filename: example.ts +// ---cut--- +// [!include ~/snippets/contracts/comments/commentWithSmartWallet.ts] +``` + +```ts twoslash [config.ts] +// [!include ~/snippets/contracts/comments/config.ts] +``` + +::: + + +### Cross-Chain commenting, sparking, and minting with comments + +An account can sign a permit to comment, spark a comment, or mint and comment on their behalf, and that permit can be used to execute the action onchain. +This enables cross-chain functionality for these actions by validating that the signer of the message is the original commenter, sparker, or minter. Here's how it works: + +1. When creating the permit, the user specifies two chain IDs: + - `sourceChainId`: The chain ID where the permit is being signed. + - `destinationChainId`: The chain ID where the permit should be executed. + +2. To enable cross-chain functionality: + - Set `sourceChainId` to the current chain where you're signing the permit. + - Set `destinationChainId` to the chain ID of the target blockchain where you want the action to be executed. + +3. The permit can then be signed on a source chain and submitted to the appropriate contract on the destination chain, allowing the action to be executed there. + +For example, if you're signing a permit on Base, but want the action to occur on Zora Network, you would set: +- `sourceChainId` to 8453 +- `destinationChainId` to 7777777 + +This process works for: +- Commenting (using the `Comments` contract) +- Sparking a comment (using the `Comments` contract) +- Minting and commenting (using the `CallerAndCommenter` helper contract) + +1. Example cross-chain commenting with Relay: + +:::code-group + +```ts twoslash [example.ts] +// @filename: config.ts +// [!include ~/snippets/contracts/comments/config.ts] + +// @filename: example.ts +// ---cut--- +// [!include ~/snippets/contracts/comments/permitComment.ts] +``` + +```ts twoslash [config.ts] +// [!include ~/snippets/contracts/comments/config.ts] +``` + +::: + +2. Example cross-chain sparking with Relay: + +:::code-group + +```ts twoslash [example.ts] +// @filename: config.ts +// [!include ~/snippets/contracts/comments/config.ts] + +// @filename: comment.ts +// [!include ~/snippets/contracts/comments/comment.ts] + +// @filename: example.ts +// ---cut--- +// [!include ~/snippets/contracts/comments/permitSparkComment.ts] +``` + +```ts twoslash [comment.ts] +// @filename: config.ts +// [!include ~/snippets/contracts/comments/config.ts] + +// @filename: comment.ts +// --cut-- +// [!include ~/snippets/contracts/comments/comment.ts] +``` + +```ts twoslash [config.ts] +// [!include ~/snippets/contracts/comments/config.ts] +``` + +::: + +3. Example cross-chain minting and commenting with Relay: + +:::code-group + +```ts twoslash [example.ts] +// @filename: config.ts +// [!include ~/snippets/contracts/comments/config.ts] + +// @filename: comment.ts +// [!include ~/snippets/contracts/comments/comment.ts] + +// @filename: example.ts +// ---cut--- +// [!include ~/snippets/contracts/comments/permitMintAndComment.ts] +``` + +```ts twoslash [comment.ts] +// @filename: config.ts +// [!include ~/snippets/contracts/comments/config.ts] + +// @filename: comment.ts +// --cut-- +// [!include ~/snippets/contracts/comments/comment.ts] +``` + +```ts twoslash [config.ts] +// [!include ~/snippets/contracts/comments/config.ts] +``` + +::: + +## Events + +The `Comments` contract emits the following events: + +```solidity +interface IComments { + /// @notice Event emitted when a comment is created + event Commented( + bytes32 indexed commentId, // Unique ID for the comment, generated from a hash of the commentIdentifier + CommentIdentifier commentIdentifier, // Identifier for the comment, containing details about the comment + bytes32 replyToId, // Unique ID of the comment being replied to (if any) + CommentIdentifier replyTo, // Identifier of the comment being replied to (if any) + uint256 sparksQuantity, // Number of sparks associated with this comment + string text, // The actual text content of the comment + uint256 timestamp, // Timestamp when the comment was created + address referrer // Address of the referrer who referred the commenter, if any + ); + + // Event emitted when a comment is backfilled + event BackfilledComment( + bytes32 indexed commentId, // Unique identifier for the backfilled comment + CommentIdentifier commentIdentifier, // Identifier for the comment + string text, // The actual text content of the backfilled comment + uint256 timestamp, // Timestamp when the original comment was created + bytes32 originalTransactionId // Transaction ID of the original comment (before backfilling) + ); + + // Event emitted when a comment is Sparked + event SparkedComment( + bytes32 indexed commentId, // Unique identifier of the comment being sparked + CommentIdentifier commentIdentifier, // Struct containing details about the comment and commenter + uint256 sparksQuantity, // Number of sparks added to the comment + address sparker, // Address of the user who sparked the comment + uint256 timestamp, // Timestamp when the spark action occurred + address referrer // Address of the referrer who referred the sparker, if any + ); +} +``` + +When minting and commenting, the `MintedAndCommented` event is emitted from the minter contract, containing +more contextual information about the mint and comment, as well as a link to the comment via the comment identifier: + +```solidity +interface IZoraTimedSaleStrategy { + /// @notice MintedAndCommented Event, emitted when a user mints and comments + /// @param commentId The comment ID + /// @param commentIdentifier The comment identifier + /// @param mintQuantity The quantity of tokens minted + /// @param comment The comment + event MintedAndCommented(bytes32 indexed commentId, IComments.CommentIdentifier commentIdentifier, uint256 mintQuantity, string comment); +} +``` diff --git a/docs/snippets/contracts/comments/comment.ts b/docs/snippets/contracts/comments/comment.ts new file mode 100644 index 000000000..58047252f --- /dev/null +++ b/docs/snippets/contracts/comments/comment.ts @@ -0,0 +1,69 @@ +import { + commentsABI, + commentsAddress, + emptyCommentIdentifier, +} from "@zoralabs/protocol-deployments"; +import { zeroAddress, parseEther, parseEventLogs } from "viem"; +import { + publicClient, + walletClient, + chainId, + commenterAccount, + contractAddress1155, + tokenId1155, +} from "./config"; + +// if no referrer, use zero address +const referrer = zeroAddress; + +// if no smart wallet owner, use zero address +const smartWallet = zeroAddress; + +const sparkValue = parseEther("0.000001"); + +// comment that we are replying to. If there is no reply, use emptyCommentIdentifier() from @zoralabs/protocol-deployments +const replyTo = emptyCommentIdentifier(); +// comment on token, paying the value of 1 spark to the contract +const hash = await walletClient.writeContract({ + abi: commentsABI, + address: commentsAddress[chainId], + functionName: "comment", + args: [ + // account that is attributed with the comment; must match the account that is executing the transaction + commenterAccount, + // 1155 contract address to comment on. Must be an admin or owner of the token. + contractAddress1155, + // tokenId of the token to comment on + tokenId1155, + // text content of the comment + "This is a test comment", + // empty reply to, since were not replying to any other comment + replyTo, + // optional smart wallet. smart wallet can be an owner or creator of the 1155 token. + // and eoa that is the owner of the smart wallet can comment. + smartWallet, + // Optional referrer address to receive a portion of the sparks value + referrer, + ], + // account that is executing the transaction. Must match the commenterAccount argument above. + account: commenterAccount, + // pay the value of 1 spark to the contract + value: sparkValue, +}); + +// wait for comment to complete - make sure it succeeds +const receipt = await publicClient.waitForTransactionReceipt({ hash }); +if (receipt.status !== "success") { + throw new Error("Transaction failed"); +} + +// we can get the comment identifier from the Commented event in the receipt logs +const commentedEvent = parseEventLogs({ + abi: commentsABI, + eventName: "Commented", + logs: receipt.logs, +})[0]!; + +const commentIdentifier = commentedEvent.args.commentIdentifier; + +export { commentIdentifier }; diff --git a/docs/snippets/contracts/comments/commentWithSmartWallet.ts b/docs/snippets/contracts/comments/commentWithSmartWallet.ts new file mode 100644 index 000000000..83338ce78 --- /dev/null +++ b/docs/snippets/contracts/comments/commentWithSmartWallet.ts @@ -0,0 +1,87 @@ +import { + zoraTimedSaleStrategyABI, + zoraTimedSaleStrategyAddress, + commentsABI, + commentsAddress, + emptyCommentIdentifier, + sparkValue, +} from "@zoralabs/protocol-deployments"; + +import { + publicClient, + walletClient, + chainId, + smartWalletOwner, + contractAddress1155, + tokenId1155, + bundlerClient, + smartWalletAccount, +} from "./config"; + +import { zeroAddress } from "viem"; + +// 1. Mint as the smart wallet + +// fist perform the mint as the smart wallet via the bundler, making the +// smart wallet the owner of the token; we simulate it first. +const userOperationResponse = await bundlerClient.prepareUserOperation({ + account: smartWalletAccount, + calls: [ + { + abi: zoraTimedSaleStrategyABI, + to: zoraTimedSaleStrategyAddress[chainId], + functionName: "mint", + args: [ + smartWalletAccount.address, + 1n, + contractAddress1155, + tokenId1155, + zeroAddress, + "0", + ], + }, + ], +}); + +// send the user operation with the bundler +const hash = await bundlerClient.sendUserOperation(userOperationResponse); +// ensure the user operation is accepted +const mintReceipt = await bundlerClient.waitForUserOperationReceipt({ hash }); +if (!mintReceipt.success) { + throw new Error("Mint failed"); +} + +// 2. Comment as the smart wallet owner + +// We comment as an owner of the smart wallet. The contract allows for commenting +// when a smart wallet owned by an account is the owner of the token. + +const referrer = zeroAddress; + +// now perform the comment as a smart wallet owner +const commentHash = await walletClient.writeContract({ + abi: commentsABI, + address: commentsAddress[chainId], + functionName: "comment", + args: [ + // commenter account is an account that owns the smart wallet + smartWalletOwner.address, // [!code focus] + contractAddress1155, + tokenId1155, + "This is a test reply", + // when replying to a comment, we must pass the comment identifier of the comment we are replying to + emptyCommentIdentifier(), + // we set the smart wallet parameter. the smart wallet can be checked to see if it is the owner of the token + smartWalletAccount.address, // [!code focus] + referrer, + ], + account: smartWalletOwner, // [!code focus] + value: sparkValue(), +}); + +const receipt = await publicClient.waitForTransactionReceipt({ + hash: commentHash, +}); +if (receipt.status !== "success") { + throw new Error("Transaction failed"); +} diff --git a/docs/snippets/contracts/comments/config.ts b/docs/snippets/contracts/comments/config.ts new file mode 100644 index 000000000..2c5fa562f --- /dev/null +++ b/docs/snippets/contracts/comments/config.ts @@ -0,0 +1,52 @@ +import "viem/window"; + +// ---cut--- +import { zoraSepolia } from "viem/chains"; +import { + http, + custom, + createPublicClient, + createWalletClient, + Chain, +} from "viem"; +import { + createBundlerClient, + toCoinbaseSmartAccount, +} from "viem/account-abstraction"; +import { privateKeyToAccount } from "viem/accounts"; + +export const chain = zoraSepolia; +export const chainId = zoraSepolia.id; + +export const publicClient = createPublicClient({ + // this will determine which chain to interact with + chain: chain as Chain, + transport: http(), +}); + +export const walletClient = createWalletClient({ + chain: chain as Chain, + transport: custom(window.ethereum!), +}); + +export const bundlerClient = createBundlerClient({ + client: publicClient, + transport: http("https://public.pimlico.io/v2/1/rpc"), +}); + +export const creatorAccount = (await walletClient.getAddresses())[0]!; +export const minterAccount = (await walletClient.getAddresses())[1]!; +export const randomAccount = (await walletClient.getAddresses())[2]!; +export const commenterAccount = (await walletClient.getAddresses())[3]!; +export const sparkerAccount = (await walletClient.getAddresses())[4]!; +export const smartWalletOwner = privateKeyToAccount( + "0x387c307228bee9b7639f73f3aecb1eebcba919f061ca92cb7001727f5b30a0ec", +); + +export const contractAddress1155 = "0xD42557F24034b53e7340A40bb5813eF9Ba88F2b4"; +export const tokenId1155 = 3n; + +export const smartWalletAccount = await toCoinbaseSmartAccount({ + client: publicClient, + owners: [], +}); diff --git a/docs/snippets/contracts/comments/mintAndComment.ts b/docs/snippets/contracts/comments/mintAndComment.ts new file mode 100644 index 000000000..f03ee8b2e --- /dev/null +++ b/docs/snippets/contracts/comments/mintAndComment.ts @@ -0,0 +1,40 @@ +import { + callerAndCommenterABI, + callerAndCommenterAddress, +} from "@zoralabs/protocol-deployments"; +import { + publicClient, + walletClient, + chainId, + minterAccount, + contractAddress1155, + tokenId1155, +} from "./config"; +import { parseEther, zeroAddress } from "viem"; + +const mintFee = parseEther("0.000111"); + +const commenter = minterAccount; +const quantityToMint = 3n; +const collection = contractAddress1155; +const tokenId = tokenId1155; +const mintReferral = zeroAddress; +const comment = "This is a test comment"; + +// minting and commenting in one transaction, calling the `timedSaleMintAndComment` function +// on the `CallerAndCommenter` contract +const hash = await walletClient.writeContract({ + abi: callerAndCommenterABI, + address: callerAndCommenterAddress[chainId], + functionName: "timedSaleMintAndComment", + args: [commenter, quantityToMint, collection, tokenId, mintReferral, comment], + account: commenter, + // when minting and commenting, only the mint fee needs to be paid; + // no additional ETH is required to pay for commenting + value: mintFee * quantityToMint, +}); + +const receipt = await publicClient.waitForTransactionReceipt({ hash }); +if (receipt.status !== "success") { + throw new Error("Transaction failed"); +} diff --git a/docs/snippets/contracts/comments/permitComment.ts b/docs/snippets/contracts/comments/permitComment.ts new file mode 100644 index 000000000..b3f0de989 --- /dev/null +++ b/docs/snippets/contracts/comments/permitComment.ts @@ -0,0 +1,87 @@ +import { + commentsABI, + commentsAddress, + emptyCommentIdentifier, + permitCommentTypedDataDefinition, + PermitComment, + sparkValue, +} from "@zoralabs/protocol-deployments"; +import { getClient } from "@reservoir0x/relay-sdk"; +import { zeroAddress, keccak256, toBytes, encodeFunctionData } from "viem"; +import { base, zora } from "viem/chains"; +import { + walletClient, + chainId, + commenterAccount, + contractAddress1155, + tokenId1155, +} from "./config"; + +// 1. Create and sign a cross-chain permit comment. + +// Calculate a timestamp 30 seconds from now +const thirtySecondsFromNow = + BigInt(Math.round(new Date().getTime() / 1000)) + 30n; +// Generate a random nonce +const randomNonce = () => keccak256(toBytes(Math.round(Math.random() * 1000))); + +// Get the comments contract address for the current chain +const commentsContractAddress = commentsAddress[chainId]; + +// Define the permit comment object +const permitComment: PermitComment = { + sourceChainId: base.id, // The chain where the transaction originates (Base) + destinationChainId: zora.id, // The chain where the comment will be stored (Zora) + contractAddress: contractAddress1155, // The address of the 1155 contract + tokenId: tokenId1155, // The 1155 token ID being commented on + commenter: commenterAccount, // The account making the comment. + text: "hello world", // The content of the comment + deadline: thirtySecondsFromNow, + nonce: randomNonce(), + referrer: zeroAddress, // No referrer in this case + commenterSmartWallet: zeroAddress, // Not using a smart wallet for commenting + replyTo: emptyCommentIdentifier(), // This is not a reply to another comment +}; + +// Generate the typed data for the permit comment using the helper +// method from @zoralabs/protocol-deployments +const permitCommentTypedData = permitCommentTypedDataDefinition(permitComment); + +// Sign the permit +const permitCommentSignature = await walletClient.signTypedData( + permitCommentTypedData, +); + +// 2. Execute the cross-chain transaction with relay + +// Initialize the relay client +const relayClient = getClient(); + +// Get a quote from relay for the cross-chain transaction +const quote = await relayClient.actions.getQuote({ + wallet: walletClient, + chainId: permitComment.sourceChainId, // The origin chain (Base) + toChainId: permitComment.destinationChainId, // The destination chain (Zora) + amount: sparkValue().toString(), // The value to send to the comments contract on the destination chain + tradeType: "EXACT_OUTPUT", + currency: zeroAddress, // ETH + toCurrency: zeroAddress, // ETH + txs: [ + { + to: commentsContractAddress, + value: sparkValue().toString(), + // we will call permitComment on the destination chain + data: encodeFunctionData({ + abi: commentsABI, + functionName: "permitComment", + args: [permitComment, permitCommentSignature], + }), + }, + ], +}); + +// Execute the cross-chain transaction +await relayClient.actions.execute({ + quote, + wallet: walletClient, // The wallet initiating the transaction +}); diff --git a/docs/snippets/contracts/comments/permitMintAndComment.ts b/docs/snippets/contracts/comments/permitMintAndComment.ts new file mode 100644 index 000000000..8fbb6cddb --- /dev/null +++ b/docs/snippets/contracts/comments/permitMintAndComment.ts @@ -0,0 +1,95 @@ +import { + callerAndCommenterAddress, + callerAndCommenterABI, + permitMintAndCommentTypedDataDefinition, + PermitMintAndComment, +} from "@zoralabs/protocol-deployments"; +import { getClient } from "@reservoir0x/relay-sdk"; +import { + zeroAddress, + keccak256, + toBytes, + encodeFunctionData, + parseEther, +} from "viem"; +import { base, zora } from "viem/chains"; +import { + walletClient, + minterAccount, + contractAddress1155, + tokenId1155, +} from "./config"; + +// 1. Create and sign a cross-chain permit mint and comment. + +// Calculate a timestamp 30 seconds from now +const thirtySecondsFromNow = + BigInt(Math.round(new Date().getTime() / 1000)) + 30n; +// Generate a random nonce +const randomNonce = () => keccak256(toBytes(Math.round(Math.random() * 1000))); + +// Define the number of 1155 tokens to mint +const quantityToMint = 3n; + +// Define the permit mint and comment object +const permit: PermitMintAndComment = { + commenter: minterAccount, + comment: "This is a test comment", + deadline: thirtySecondsFromNow, + mintReferral: zeroAddress, // No mint referral in this case + quantity: quantityToMint, + collection: contractAddress1155, + tokenId: tokenId1155, + nonce: randomNonce(), + sourceChainId: base.id, // The chain where the transaction originates (Base) + destinationChainId: zora.id, // The chain where the mint and comment will be executed (Zora) +}; + +// Generate the typed data for the permit mint and comment using the helper +// method from @zoralabs/protocol-deployments +const typedData = permitMintAndCommentTypedDataDefinition(permit); + +// Sign the permit +const signature = await walletClient.signTypedData(typedData); + +const mintFee = parseEther("0.000111"); + +// 2. Execute the cross-chain transaction with relay + +// Initialize the relay client +const relayClient = getClient(); + +// Value to send to the CallerAndCommenter contract on the destination chain +// is the mint fee multiplied by the quantity of tokens to mint +const valueToSend = mintFee * quantityToMint; + +// Get a quote from relay for the cross-chain transaction +const quote = await relayClient.actions.getQuote({ + wallet: walletClient, + chainId: permit.sourceChainId, // The origin chain (Base) + toChainId: permit.destinationChainId, // The destination chain (Zora) + amount: valueToSend.toString(), // The total value to send to the CallerAndCommenter contract on the destination chain + tradeType: "EXACT_OUTPUT", + currency: zeroAddress, // ETH + toCurrency: zeroAddress, // ETH + txs: [ + { + to: callerAndCommenterAddress[ + permit.destinationChainId as keyof typeof callerAndCommenterAddress + ], + value: valueToSend.toString(), + // We will call permitTimedSaleMintAndComment on the destination chain + data: encodeFunctionData({ + abi: callerAndCommenterABI, + functionName: "permitTimedSaleMintAndComment", + args: [permit, signature], + }), + }, + ], +}); + +// Execute the cross-chain transaction +await relayClient.actions.execute({ + quote, + wallet: walletClient, // The wallet initiating the transaction +}); diff --git a/docs/snippets/contracts/comments/permitSparkComment.ts b/docs/snippets/contracts/comments/permitSparkComment.ts new file mode 100644 index 000000000..9e1f0e4a6 --- /dev/null +++ b/docs/snippets/contracts/comments/permitSparkComment.ts @@ -0,0 +1,82 @@ +import { + commentsABI, + commentsAddress, + PermitSparkComment, + sparkValue, + permitSparkCommentTypedDataDefinition, +} from "@zoralabs/protocol-deployments"; +import { getClient } from "@reservoir0x/relay-sdk"; +import { zeroAddress, keccak256, toBytes, encodeFunctionData } from "viem"; +import { base, zora } from "viem/chains"; +import { walletClient, chainId, sparkerAccount } from "./config"; +import { commentIdentifier } from "./comment"; + +// 1. Create and sign a cross-chain permit spark comment. + +// Calculate a timestamp 30 seconds from now +const thirtySecondsFromNow = + BigInt(Math.round(new Date().getTime() / 1000)) + 30n; +// Generate a random nonce +const randomNonce = () => keccak256(toBytes(Math.round(Math.random() * 1000))); + +// Get the comments contract address for the current chain +const commentsContractAddress = commentsAddress[chainId]; + +// Define the number of sparks to add +const sparksQuantity = 3n; + +// Define the permit spark comment object +const permitSparkComment: PermitSparkComment = { + comment: commentIdentifier, // The comment to spark + deadline: thirtySecondsFromNow, + nonce: randomNonce(), + sparker: sparkerAccount, // The account sparking the comment + sparksQuantity: sparksQuantity, // The number of sparks to add + sourceChainId: base.id, // The chain where the transaction originates (Base) + destinationChainId: zora.id, // The chain where the spark will be stored (Zora) + referrer: zeroAddress, // No referrer in this case +}; + +// Generate the typed data for the permit spark comment using the helper +// method from @zoralabs/protocol-deployments +const permitSparkCommentTypedData = + permitSparkCommentTypedDataDefinition(permitSparkComment); + +// Sign the permit +const permitSparkCommentSignature = await walletClient.signTypedData( + permitSparkCommentTypedData, +); + +// 2. Execute the cross-chain transaction with relay + +// Initialize the relay client +const relayClient = getClient(); + +// Get a quote from relay for the cross-chain transaction +const quote = await relayClient.actions.getQuote({ + wallet: walletClient, + chainId: permitSparkComment.sourceChainId, // The origin chain (Base) + toChainId: permitSparkComment.destinationChainId, // The destination chain (Zora) + amount: (sparkValue() * sparksQuantity).toString(), // The total value to send to the comments contract on the destination chain + tradeType: "EXACT_OUTPUT", + currency: zeroAddress, // ETH + toCurrency: zeroAddress, // ETH + txs: [ + { + to: commentsContractAddress, + value: sparkValue().toString(), + // We will call permitSparkComment on the destination chain + data: encodeFunctionData({ + abi: commentsABI, + functionName: "permitSparkComment", + args: [permitSparkComment, permitSparkCommentSignature], + }), + }, + ], +}); + +// Execute the cross-chain transaction +await relayClient.actions.execute({ + quote, + wallet: walletClient, // The wallet initiating the transaction +}); diff --git a/docs/snippets/contracts/comments/referrer.ts b/docs/snippets/contracts/comments/referrer.ts new file mode 100644 index 000000000..cfacefd34 --- /dev/null +++ b/docs/snippets/contracts/comments/referrer.ts @@ -0,0 +1,47 @@ +import { + commentsABI, + commentsAddress, + emptyCommentIdentifier, +} from "@zoralabs/protocol-deployments"; +import { zeroAddress, parseEther } from "viem"; +import { + publicClient, + walletClient, + chainId, + commenterAccount, + contractAddress1155, + tokenId1155, +} from "./config"; + +// referrer is the address that will receive a portion of the Sparks value +const referrer = "0x1234567890123456789012345678901234567890"; // [!code focus] + +const sparkValue = parseEther("0.000001"); + +const replyTo = emptyCommentIdentifier(); +// comment on token, paying the value of 1 spark to the contract +const hash = await walletClient.writeContract({ + abi: commentsABI, + address: commentsAddress[chainId], + functionName: "comment", + args: [ + commenterAccount, + contractAddress1155, + tokenId1155, + "This is a test comment", + replyTo, + zeroAddress, + // Optional referrer address to receive a portion of the Sparks value + referrer, // [!code focus] + ], + // account that is executing the transaction. Must match the commenterAccount argument above. + account: commenterAccount, + // pay the value of 1 spark to the contract + value: sparkValue, +}); + +// wait for comment to complete - make sure it succeeds +const receipt = await publicClient.waitForTransactionReceipt({ hash }); +if (receipt.status !== "success") { + throw new Error("Transaction failed"); +} diff --git a/docs/snippets/contracts/comments/reply.ts b/docs/snippets/contracts/comments/reply.ts new file mode 100644 index 000000000..ea2e72aa5 --- /dev/null +++ b/docs/snippets/contracts/comments/reply.ts @@ -0,0 +1,54 @@ +import { + commentsABI, + commentsAddress, + CommentIdentifier, +} from "@zoralabs/protocol-deployments"; +import { + commenterAccount, + contractAddress1155, + publicClient, + tokenId1155, + walletClient, + chainId, +} from "./config"; +import { commentIdentifier } from "./comment"; +import { zeroAddress, parseEther } from "viem"; + +const sparkValue = parseEther("0.000001"); + +// this identifies the comment that we are replying to +// it can be gotten from the `Commented` event when commenting, +// or from the subgraph when querying for comments +const replyTo: CommentIdentifier = { + commenter: commentIdentifier.commenter, // [!code hl] + contractAddress: commentIdentifier.contractAddress, // [!code hl] + tokenId: commentIdentifier.tokenId, // [!code hl] + nonce: commentIdentifier.nonce, // [!code hl] +}; + +const referrer = zeroAddress; + +const smartWallet = zeroAddress; + +const hash = await walletClient.writeContract({ + abi: commentsABI, + address: commentsAddress[chainId], + functionName: "comment", + args: [ + commenterAccount, + contractAddress1155, + tokenId1155, + "This is a test reply", + // when replying to a comment, we must pass the comment identifier of the comment we are replying to + replyTo, // [!code hl] + smartWallet, + referrer, + ], + account: commenterAccount, + value: sparkValue, +}); + +const receipt = await publicClient.waitForTransactionReceipt({ hash }); +if (receipt.status !== "success") { + throw new Error("Transaction failed"); +} diff --git a/docs/snippets/contracts/comments/sparking.ts b/docs/snippets/contracts/comments/sparking.ts new file mode 100644 index 000000000..7f15e3e70 --- /dev/null +++ b/docs/snippets/contracts/comments/sparking.ts @@ -0,0 +1,43 @@ +import { + commentsABI, + commentsAddress, + CommentIdentifier, +} from "@zoralabs/protocol-deployments"; +import { + commenterAccount, + contractAddress1155, + publicClient, + tokenId1155, + walletClient, + chainId, + sparkerAccount, +} from "./config"; +import { keccak256, toBytes, zeroAddress, parseEther } from "viem"; + +// quantity of sparks to spark (like) the comment with +const sparksQuantity = 1n; + +const sparkValue = parseEther("0.000001"); + +const commentIdentifier: CommentIdentifier = { + commenter: commenterAccount, + contractAddress: contractAddress1155, + tokenId: tokenId1155, + nonce: keccak256(toBytes(1)), +}; + +const referrer = zeroAddress; + +const hash = await walletClient.writeContract({ + abi: commentsABI, + address: commentsAddress[chainId], + functionName: "sparkComment", + args: [commentIdentifier, sparksQuantity, referrer], + account: sparkerAccount, + value: sparksQuantity * sparkValue, +}); + +const receipt = await publicClient.waitForTransactionReceipt({ hash }); +if (receipt.status !== "success") { + throw new Error("Transaction failed"); +} diff --git a/docs/snippets/protocol-sdk/collect/buy1155OnSecondary.ts b/docs/snippets/protocol-sdk/collect/buy1155OnSecondary.ts index 112605478..fee74a1e3 100644 --- a/docs/snippets/protocol-sdk/collect/buy1155OnSecondary.ts +++ b/docs/snippets/protocol-sdk/collect/buy1155OnSecondary.ts @@ -22,6 +22,8 @@ const { parameters, price, error } = await collectorClient.buy1155OnSecondary({ quantity: 3n, // account that will execute the buy transaction account: address!, + // (optional) comment to add to the swap + comment: "test comment", }); if (error) { diff --git a/docs/vocs.config.ts b/docs/vocs.config.ts index 0fc59712a..9a4c3d8dc 100644 --- a/docs/vocs.config.ts +++ b/docs/vocs.config.ts @@ -124,6 +124,10 @@ export default defineConfig({ }, ], }, + { + text: "Comments", + link: "/contracts/Comments", + }, ], }, ], diff --git a/packages/1155-deployments/foundry.toml b/packages/1155-deployments/foundry.toml index 7937a205d..f47d53590 100644 --- a/packages/1155-deployments/foundry.toml +++ b/packages/1155-deployments/foundry.toml @@ -3,6 +3,8 @@ fs_permissions = [ { access = "read", path = "./versions" }, { access = "read", path = "./chainConfigs" }, { access = "read", path = "./package.json" }, + { access = "read", path = "../erc20z" }, + { access = "read", path = "../comments" }, { access = "read", path = "../sparks/deterministicConfig" }, { access = "read", path = "../sparks/addresses" }, { access = "read", path = "../erc20z/addresses" }, diff --git a/packages/1155-deployments/src/DeploymentConfig.sol b/packages/1155-deployments/src/DeploymentConfig.sol index f2dc4381c..958f3061a 100644 --- a/packages/1155-deployments/src/DeploymentConfig.sol +++ b/packages/1155-deployments/src/DeploymentConfig.sol @@ -119,5 +119,15 @@ abstract contract DeploymentConfig is Script { string memory json = vm.readFile("../sparks/deterministicConfig/sparksProxy.json"); return json.readAddress(".manager.deployedAddress"); } + + function getDeterminsticCommentsAddress() internal view returns (address) { + string memory json = vm.readFile("../comments/deterministicConfig/comments.json"); + return json.readAddress(".deployedAddress"); + } + + function getDeterminsticZoraTimedSaleStrategyAddress() internal view returns (address) { + string memory json = vm.readFile("../erc20z/deterministicConfig/zoraTimedSaleStrategy.json"); + return json.readAddress(".deployedAddress"); + } } diff --git a/packages/1155-deployments/test/UpgradesTestBase.sol b/packages/1155-deployments/test/UpgradesTestBase.sol index 3db962d7a..46c31b2c1 100644 --- a/packages/1155-deployments/test/UpgradesTestBase.sol +++ b/packages/1155-deployments/test/UpgradesTestBase.sol @@ -15,6 +15,7 @@ import {IZoraCreator1155} from "@zoralabs/zora-1155-contracts/src/interfaces/IZo import {IZoraCreator1155PremintExecutor} from "@zoralabs/zora-1155-contracts/src/interfaces/IZoraCreator1155PremintExecutor.sol"; import {ContractCreationConfig, PremintConfigV2} from "@zoralabs/shared-contracts/entities/Premint.sol"; import {UpgradeGate} from "@zoralabs/zora-1155-contracts/src/upgrades/UpgradeGate.sol"; +import {IVersionedContract} from "@zoralabs/shared-contracts/interfaces/IVersionedContract.sol"; interface IERC1967 { /** @@ -41,10 +42,6 @@ interface IProxyAdmin { function upgradeAndCall(ITransparentUpgradeableProxy proxy, address implementation, bytes memory data) external payable; } -interface GetImplementation { - function implementation() external view returns (address); -} - interface UUPSUpgradeable { function upgradeToAndCall(address newImplementation, bytes memory data) external payable; } @@ -88,46 +85,53 @@ contract UpgradesTestBase is ForkDeploymentConfig, DeploymentTestingUtils, Test, return UpgradeStatus("Preminter", upgradeNeeded, upgradeTarget, targetImpl, upgradeCalldata); } - function tryReadSparksImpl() private view returns (address mintsImpl) { - string memory addressPath = string.concat("../sparks/addresses/", string.concat(vm.toString(block.chainid), ".json")); + function tryReadImpl(string memory packageName, string memory keyName) private view returns (address impl) { + string memory addressPath = string.concat("../", packageName, "/addresses/", vm.toString(block.chainid), ".json"); try vm.readFile(addressPath) returns (string memory result) { - mintsImpl = result.readAddress(".SPARKS_MANAGER_IMPL"); + impl = result.readAddress(string.concat(".", keyName)); } catch {} } - function mintsIsDeployed() private view returns (bool) { - return tryReadSparksImpl() != address(0); - } - - function determineSparksUpgrade() private view returns (UpgradeStatus memory) { - address mintsManagerProxy = getDeterminsticSparksManagerAddress(); - - address targetImpl = tryReadSparksImpl(); + function determineUpgrade(string memory name, address proxy, string memory packageName, string memory implKey) private view returns (UpgradeStatus memory) { + address targetImpl = tryReadImpl(packageName, implKey); if (targetImpl == address(0)) { - console2.log("Sparks not deployed"); - UpgradeStatus memory upgradeStatus; - return upgradeStatus; + console2.log(string.concat(name, " not deployed")); + return UpgradeStatus("", false, address(0), address(0), ""); } if (targetImpl.code.length == 0) { - revert("No code at target impl"); + revert(string.concat("No code at target impl for ", name)); } - bool upgradeNeeded = GetImplementation(mintsManagerProxy).implementation() != targetImpl; - - address upgradeTarget = mintsManagerProxy; + bool upgradeNeeded = UpgradeBaseLib.getUpgradeNeeded(proxy, targetImpl); bytes memory upgradeCalldata; - if (upgradeNeeded) { - // in the case of transparent proxy - the upgrade target is the proxy admin contract. - // get upgrade calldata upgradeCalldata = abi.encodeWithSelector(UUPSUpgradeable.upgradeToAndCall.selector, targetImpl, ""); } - return UpgradeStatus("Sparks", upgradeNeeded, upgradeTarget, targetImpl, upgradeCalldata); + return UpgradeStatus(name, upgradeNeeded, proxy, targetImpl, upgradeCalldata); } + function determineSparksUpgrade() private view returns (UpgradeStatus memory) { + address mintsManagerProxy = getDeterminsticSparksManagerAddress(); + return determineUpgrade("Sparks", mintsManagerProxy, "sparks-deployments", "SPARKS_MANAGER_IMPL"); + } + + function determineCommentsUpgrade() private view returns (UpgradeStatus memory) { + address commentsProxy = tryReadImpl("comments", "COMMENTS"); + return determineUpgrade("Comments", commentsProxy, "comments", "COMMENTS_IMPL"); + } + + function determineCallerAndCommenterUpgrade() private view returns (UpgradeStatus memory) { + address callerAndCommenterProxy = tryReadImpl("comments", "CALLER_AND_COMMENTER"); + return determineUpgrade("CallerAndCommenter", callerAndCommenterProxy, "comments", "CALLER_AND_COMMENTER_IMPL"); + } + + function determintZoraTimedSaleStrategyUpgrade() private view returns (UpgradeStatus memory) { + address timedSaleStrategyProxy = getDeterminsticZoraTimedSaleStrategyAddress(); + return determineUpgrade("Zora Timed Sale Strategy", timedSaleStrategyProxy, "erc20z", "SALE_STRATEGY_IMPL"); + } function checkPremintingWorks() private { console2.log("testing preminting"); @@ -150,62 +154,8 @@ contract UpgradesTestBase is ForkDeploymentConfig, DeploymentTestingUtils, Test, vm.stopPrank(); } - function checkPremintWithMINTsWorks() private { - if (!mintsIsDeployed()) { - console2.log("skipping premint with MINTs test, MINTs not deployed"); - return; - } - console2.log("testing collecing premints with MINTs"); - // test premints: - address collector = makeAddr("collector"); - vm.deal(collector, 10 ether); - - IZoraSparksManager zoraSparksManager = IZoraSparksManager(getDeterminsticSparksManagerAddress()); - - // address[] memory mintRewardsRecipients = new address[](0); - - // MintArguments memory mintArguments = MintArguments({mintRecipient: collector, mintComment: "", mintRewardsRecipients: mintRewardsRecipients}); - - // uint256 quantityToMint = 5; - - // vm.startPrank(collector); - - // zoraSparksManager.mintWithEth{value: zoraSparksManager.getEthPrice() * quantityToMint}(quantityToMint, collector); - - // uint256[] memory mintTokenIds = new uint256[](1); - // mintTokenIds[0] = zoraSparksManager.mintableEthToken(); - // uint256[] memory quantities = new uint256[](1); - // quantities[0] = 3; - - // (ContractCreationConfig memory contractConfig, , PremintConfigV2 memory premintConfig, bytes memory signature) = createAndSignPremintV2( - // getDeployment().preminterProxy, - // makeAddr("payoutRecipientG"), - // 10_000 - // ); - - // bytes memory call = abi.encodeWithSelector( - // ICollectWithZoraSparks.collectPremintV2.selector, - // contractConfig, - // premintConfig, - // signature, - // mintArguments, - // address(0) - // ); - - // PremintResult memory result = abi.decode( - // IZoraSparks1155Managed(address(zoraSparksManager.zoraSparks1155())).transferBatchToManagerAndCall(mintTokenIds, quantities, call), - // (PremintResult) - // ); - - // assertEq(IZoraCreator1155(result.contractAddress).balanceOf(collector, result.tokenId), quantities[0]); - - vm.stopPrank(); - } - function checkContracts() private { checkPremintingWorks(); - - checkPremintWithMINTsWorks(); } function checkRegisterUpgradePaths() private returns (address[] memory upgradePathTargets, bytes[] memory upgradePathCalls) { @@ -250,11 +200,13 @@ contract UpgradesTestBase is ForkDeploymentConfig, DeploymentTestingUtils, Test, ChainConfig memory chainConfig = getChainConfig(); - UpgradeStatus[] memory upgradeStatuses = new UpgradeStatus[](4); - UpgradeStatus memory upgrade1155 = determine1155Upgrade(deployment); - upgradeStatuses[0] = upgrade1155; + UpgradeStatus[] memory upgradeStatuses = new UpgradeStatus[](6); + upgradeStatuses[0] = determine1155Upgrade(deployment); upgradeStatuses[1] = determinePreminterUpgrade(deployment); upgradeStatuses[2] = determineSparksUpgrade(); + upgradeStatuses[3] = determineCommentsUpgrade(); + upgradeStatuses[4] = determineCallerAndCommenterUpgrade(); + upgradeStatuses[5] = determintZoraTimedSaleStrategyUpgrade(); bool upgradePerformed = performNeededUpgrades(chainConfig.factoryOwner, upgradeStatuses); diff --git a/packages/comments/.env.example b/packages/comments/.env.example new file mode 100644 index 000000000..95c73c1e3 --- /dev/null +++ b/packages/comments/.env.example @@ -0,0 +1,11 @@ +ALCHEMY_KEY= +TENDERLY_KEY= + +CONDUIT_KEY= + +# Turnkey authentication and private key information +TURNKEY_API_PUBLIC_KEY="" +TURNKEY_API_PRIVATE_KEY="" +TURNKEY_ORGANIZATION_ID="" +TURNKEY_PRIVATE_KEY_ID="xxxx-xxx-xxxx-xxxx-xxxxxxxx" +TURNKEY_TARGET_ADDRESS="xxxxxxxx" \ No newline at end of file diff --git a/packages/comments/.gitignore b/packages/comments/.gitignore new file mode 100644 index 000000000..1779245ac --- /dev/null +++ b/packages/comments/.gitignore @@ -0,0 +1,18 @@ +# Compiler files +cache/ +out/ + +# Ignores development broadcast logs +!/broadcast +/broadcast/*/31337/ +/broadcast/**/dry-run/ +broadcast/ + +# Docs +docs/ + +# Dotenv file +.env + + +scripts/dumped-comments \ No newline at end of file diff --git a/packages/comments/README.md b/packages/comments/README.md new file mode 100644 index 000000000..8212b026e --- /dev/null +++ b/packages/comments/README.md @@ -0,0 +1,41 @@ +# Comments + +## Deployment + +The `Comments` contract is deployed deterministically using a turnkey account. The deployment process uses a helper contract, [DeterministicDeployerAndCaller](../../packages/shared-contracts/src/deployment/DeterministicDeployerAndCaller.sol). + +### Prerequisites + +- Ensure you have [Forge](https://book.getfoundry.sh/getting-started/installation) installed. +- Familiarity with [turnkey accounts](https://docs.turnkey.com/) is recommended. + +### Setting up environment variables + +In the `packages/comments` directory: + +1. Copy `.env.example` to `.env` +2. Populate the parameters in `.env` + +### Deploying the Comments Contracts + +1. Deploy the `Comments` contract, you must pass the `--ffi` flag to enable calling an external script to sign the deployment with turnkey: + +```bash +forge script script/Deploy.s.sol $(chains {chainName} --deploy) --broadcast --verify --ffi +``` + +where `{chainName}` is the emdash name of the chain you want to deploy on. + +2. Verify the proxy contracts. Since they are deployed with create2, foundry wont always recognize the deployed contract, so verification needs to happen manually: + +for the comments contract: + +```bash +forge verify-contract 0x7777777C2B3132e03a65721a41745C07170a5877 Comments $(chains {chainName} --verify) --constructor-args 0x000000000000000000000000064de410ce7aba82396332c5837b4c6b96108283 +``` + +for the caller and commenter contract: + +```bash +forge verify-contract 0x77777775C5074b74540d9cC63Dd840A8c692B4B5 CallerAndCommenter $(chains {chainName} --verify) --constructor-args 0x000000000000000000000000064de410ce7aba82396332c5837b4c6b96108283 +``` diff --git a/packages/comments/_imagine/Enjoy.sol b/packages/comments/_imagine/Enjoy.sol new file mode 100644 index 000000000..0cdc4cb19 --- /dev/null +++ b/packages/comments/_imagine/Enjoy.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +/* + + + + + + ░░░░░░░░░░░░░░ + ░░▒▒░░░░░░░░░░░░░░░░░░░░ + ░░▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░ + ░░▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░░ + ░▓▓▒▒▒▒░░░░░░░░░░░░ ░░░░░░░ + ░▓▓▓▒▒▒▒░░░░░░░░░░░░ ░░░░░░░░ + ░▓▓▓▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░░░░ + ░▓▓▓▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░ + ░▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░ + ░▓▓▓▓▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░ + ░░▓▓▓▓▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░ + ░░▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒░░ + ░░▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ + ░░▓▓▓▓▓▓▓▓▓▓▓▓▒▒░░░ + + OURS TRULY, + + + + + + + + + + + + */ + +interface Enjoy { + +} diff --git a/packages/comments/addresses/1.json b/packages/comments/addresses/1.json new file mode 100644 index 000000000..a714ff253 --- /dev/null +++ b/packages/comments/addresses/1.json @@ -0,0 +1,9 @@ +{ + "CALLER_AND_COMMENTER": "0x77777775C5074b74540d9cC63Dd840A8c692B4B5", + "CALLER_AND_COMMENTER_IMPL": "0x455c9D3188A3Cd94aCDE8E5Ec90cA92FC10805EA", + "CALLER_AND_COMMENTER_VERSION": "0.0.2", + "COMMENTS": "0x7777777C2B3132e03a65721a41745C07170a5877", + "COMMENTS_BLOCK_NUMBER": 21123814, + "COMMENTS_IMPL": "0x0000000000000000000000000000000000000000", + "COMMENTS_IMPL_BLOCK_NUMBER": 0 +} \ No newline at end of file diff --git a/packages/comments/addresses/10.json b/packages/comments/addresses/10.json new file mode 100644 index 000000000..76e38a4d1 --- /dev/null +++ b/packages/comments/addresses/10.json @@ -0,0 +1,9 @@ +{ + "CALLER_AND_COMMENTER": "0x77777775C5074b74540d9cC63Dd840A8c692B4B5", + "CALLER_AND_COMMENTER_IMPL": "0x0950D0eC9a7c51d5f0Fc542F54a3F72e0CB519a1", + "CALLER_AND_COMMENTER_VERSION": "0.0.2", + "COMMENTS": "0x7777777C2B3132e03a65721a41745C07170a5877", + "COMMENTS_BLOCK_NUMBER": 126971499, + "COMMENTS_IMPL": "0xA880EA1095dFcafa4cBe1C0EFa5e7b861A6634b6", + "COMMENTS_IMPL_BLOCK_NUMBER": 126971455 +} \ No newline at end of file diff --git a/packages/comments/addresses/11155111.json b/packages/comments/addresses/11155111.json new file mode 100644 index 000000000..b80f5d14c --- /dev/null +++ b/packages/comments/addresses/11155111.json @@ -0,0 +1,9 @@ +{ + "CALLER_AND_COMMENTER": "0x77777775C5074b74540d9cC63Dd840A8c692B4B5", + "CALLER_AND_COMMENTER_IMPL": "0x24581Ea0d28199d7d2275534917747Bce4e1B0A7", + "CALLER_AND_COMMENTER_VERSION": "0.0.2", + "COMMENTS": "0x7777777C2B3132e03a65721a41745C07170a5877", + "COMMENTS_BLOCK_NUMBER": 6977641, + "COMMENTS_IMPL": "0xd92F69d5581BF9536c77550aeA1d74DDaE88B5B7", + "COMMENTS_IMPL_BLOCK_NUMBER": 6977641 +} \ No newline at end of file diff --git a/packages/comments/addresses/11155420.json b/packages/comments/addresses/11155420.json new file mode 100644 index 000000000..8d704916f --- /dev/null +++ b/packages/comments/addresses/11155420.json @@ -0,0 +1,9 @@ +{ + "CALLER_AND_COMMENTER": "0x77777775C5074b74540d9cC63Dd840A8c692B4B5", + "CALLER_AND_COMMENTER_IMPL": "0x46E772210f01647FA573b915bE287Ff9b65AD4B0", + "CALLER_AND_COMMENTER_VERSION": "", + "COMMENTS": "0x7777777C2B3132e03a65721a41745C07170a5877", + "COMMENTS_BLOCK_NUMBER": 19251239, + "COMMENTS_IMPL": "0xa2a7D8bcE0bf58D177137ECB94f3Fa6aA06aA7A1.", + "COMMENTS_IMPL_BLOCK_NUMBER": 19251239 +} \ No newline at end of file diff --git a/packages/comments/addresses/42161.json b/packages/comments/addresses/42161.json new file mode 100644 index 000000000..09e86c621 --- /dev/null +++ b/packages/comments/addresses/42161.json @@ -0,0 +1,9 @@ +{ + "CALLER_AND_COMMENTER": "0x77777775C5074b74540d9cC63Dd840A8c692B4B5", + "CALLER_AND_COMMENTER_IMPL": "0x1Eb7Bf3a08784D7cB08CC2AE1448012C0c02bDa2", + "CALLER_AND_COMMENTER_VERSION": "0.0.2", + "COMMENTS": "0x7777777C2B3132e03a65721a41745C07170a5877", + "COMMENTS_BLOCK_NUMBER": 266241993, + "COMMENTS_IMPL": "0x073ef87C54c192c21ddEf881fE18064b6161fAC9", + "COMMENTS_IMPL_BLOCK_NUMBER": 266239297 +} \ No newline at end of file diff --git a/packages/comments/addresses/7777777.json b/packages/comments/addresses/7777777.json new file mode 100644 index 000000000..b9bf37e00 --- /dev/null +++ b/packages/comments/addresses/7777777.json @@ -0,0 +1,9 @@ +{ + "CALLER_AND_COMMENTER": "0x77777775C5074b74540d9cC63Dd840A8c692B4B5", + "CALLER_AND_COMMENTER_IMPL": "0x2C256f0464A9e1a4292c87eb455c5601Bcae4436", + "CALLER_AND_COMMENTER_VERSION": "0.0.2", + "COMMENTS": "0x7777777C2B3132e03a65721a41745C07170a5877", + "COMMENTS_BLOCK_NUMBER": 21297164, + "COMMENTS_IMPL": "0xFD2FDCE0d316410d3F57459DF33f88626EDF5Bc0", + "COMMENTS_IMPL_BLOCK_NUMBER": 21296491 +} diff --git a/packages/comments/addresses/81457.json b/packages/comments/addresses/81457.json new file mode 100644 index 000000000..b517b4e7a --- /dev/null +++ b/packages/comments/addresses/81457.json @@ -0,0 +1,9 @@ +{ + "CALLER_AND_COMMENTER": "0x77777775C5074b74540d9cC63Dd840A8c692B4B5", + "CALLER_AND_COMMENTER_IMPL": "0xD1F822051f3BbaDc79b7F7B4cE160525976dcD94", + "CALLER_AND_COMMENTER_VERSION": "", + "COMMENTS": "0x7777777C2B3132e03a65721a41745C07170a5877", + "COMMENTS_BLOCK_NUMBER": 11006995, + "COMMENTS_IMPL": "0x5f6b93d92ed8b51de3cb8ce135e29ca4a3a64e88", + "COMMENTS_IMPL_BLOCK_NUMBER": 0 +} diff --git a/packages/comments/addresses/8453.json b/packages/comments/addresses/8453.json new file mode 100644 index 000000000..f9b2d0da7 --- /dev/null +++ b/packages/comments/addresses/8453.json @@ -0,0 +1,9 @@ +{ + "CALLER_AND_COMMENTER": "0x77777775C5074b74540d9cC63Dd840A8c692B4B5", + "CALLER_AND_COMMENTER_IMPL": "0xaD607aF6c0b35Dd24d11693385C6aF8969d9abA8", + "CALLER_AND_COMMENTER_VERSION": "0.0.2", + "COMMENTS": "0x7777777C2B3132e03a65721a41745C07170a5877", + "COMMENTS_BLOCK_NUMBER": 21374808, + "COMMENTS_IMPL": "0x8f5B2dd6160D96B48a35F0619BC32b4b997cA37F", + "COMMENTS_IMPL_BLOCK_NUMBER": 21374465 +} \ No newline at end of file diff --git a/packages/comments/addresses/84532.json b/packages/comments/addresses/84532.json new file mode 100644 index 000000000..adc3fda22 --- /dev/null +++ b/packages/comments/addresses/84532.json @@ -0,0 +1,9 @@ +{ + "CALLER_AND_COMMENTER": "0x77777775C5074b74540d9cC63Dd840A8c692B4B5", + "CALLER_AND_COMMENTER_IMPL": "0x8e90D8cfc0CA66EA143930E4c5F7E31Bf16F722b", + "CALLER_AND_COMMENTER_VERSION": "0.0.2", + "COMMENTS": "0x7777777C2B3132e03a65721a41745C07170a5877", + "COMMENTS_BLOCK_NUMBER": 17267821, + "COMMENTS_IMPL": "0x05177c381CaC95d8DC872b14218f67C9fB903966", + "COMMENTS_IMPL_BLOCK_NUMBER": 17267821 +} \ No newline at end of file diff --git a/packages/comments/addresses/999999999.json b/packages/comments/addresses/999999999.json new file mode 100644 index 000000000..eb63ff074 --- /dev/null +++ b/packages/comments/addresses/999999999.json @@ -0,0 +1,9 @@ +{ + "CALLER_AND_COMMENTER": "0x77777775C5074b74540d9cC63Dd840A8c692B4B5", + "CALLER_AND_COMMENTER_IMPL": "0x93f84e1D51A738BaF7B19a648C8723352b935B5A", + "CALLER_AND_COMMENTER_VERSION": "", + "COMMENTS": "0x7777777C2B3132e03a65721a41745C07170a5877", + "COMMENTS_BLOCK_NUMBER": 15602117, + "COMMENTS_IMPL": "0x25352CAC829a03aA5e76c4f997ACF8Ce73A2bDA3", + "COMMENTS_IMPL_BLOCK_NUMBER": 16123316 +} \ No newline at end of file diff --git a/packages/comments/deterministicConfig/callerAndCommenter.json b/packages/comments/deterministicConfig/callerAndCommenter.json new file mode 100644 index 000000000..5b22fecfa --- /dev/null +++ b/packages/comments/deterministicConfig/callerAndCommenter.json @@ -0,0 +1,8 @@ +{ + "constructorArgs": "0x000000000000000000000000064de410ce7aba82396332c5837b4c6b96108283", + "contractName": "CallerAndCommenter", + "creationCode": "0x60a060405234610197576102b56020813803918261001c8161019c565b93849283398101031261019757516001600160a01b038116808203610197576040516001600160401b0392906020810190848211818310176101815781604052600093848252833b15610169577f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc80546001600160a01b031916821790557fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b8580a25190811561015f5783918291845af4903d15610151573d93841161013d57610103936100f3601f8201601f191660200161019c565b908152809360203d92013e6101c1565b505b7f7253512c9b5a3fa197872db4536e3869c6b1f97a9ed6d5caee0b6dbe846239e7608052604051609090816102258239608051815050f35b634e487b7160e01b83526041600452602483fd5b6101039350606092506101c1565b5050505050610105565b60249060405190634c9c8ce360e01b82526004820152fd5b634e487b7160e01b600052604160045260246000fd5b600080fd5b6040519190601f01601f191682016001600160401b0381118382101761018157604052565b906101e857508051156101d657805190602001fd5b604051630a12f52160e11b8152600490fd5b8151158061021b575b6101f9575090565b604051639996b31560e01b81526001600160a01b039091166004820152602490fd5b50803b156101f156fe608060405273ffffffffffffffffffffffffffffffffffffffff7f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc54166000808092368280378136915af43d82803e156056573d90f35b3d90fdfea264697066735822122094618cc69b365c4602a3a1eb189c1b0420aa6483dc0360a2107f8bba559c386664736f6c63430008170033000000000000000000000000064de410ce7aba82396332c5837b4c6b96108283", + "deployedAddress": "0x77777775C5074b74540d9cC63Dd840A8c692B4B5", + "deploymentCaller": "0x680E26B472d8cae8148ee21FCAd6A69D73766436", + "salt": "0x680e26b472d8cae8148ee21fcad6a69d73766436bcbd8be1b3fdfd1c95dfccbb" +} \ No newline at end of file diff --git a/packages/comments/deterministicConfig/comments.json b/packages/comments/deterministicConfig/comments.json new file mode 100644 index 000000000..b734a1d95 --- /dev/null +++ b/packages/comments/deterministicConfig/comments.json @@ -0,0 +1,8 @@ +{ + "constructorArgs": "0x000000000000000000000000064de410ce7aba82396332c5837b4c6b96108283", + "contractName": "Comments", + "creationCode": "0x60a060405234610197576102b56020813803918261001c8161019c565b93849283398101031261019757516001600160a01b038116808203610197576040516001600160401b0392906020810190848211818310176101815781604052600093848252833b15610169577f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc80546001600160a01b031916821790557fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b8580a25190811561015f5783918291845af4903d15610151573d93841161013d57610103936100f3601f8201601f191660200161019c565b908152809360203d92013e6101c1565b505b7f17973b9f7bb6d8486c56ed209226d4a5eb78ba245456ff1a1be6a2e3edba90a1608052604051609090816102258239608051815050f35b634e487b7160e01b83526041600452602483fd5b6101039350606092506101c1565b5050505050610105565b60249060405190634c9c8ce360e01b82526004820152fd5b634e487b7160e01b600052604160045260246000fd5b600080fd5b6040519190601f01601f191682016001600160401b0381118382101761018157604052565b906101e857508051156101d657805190602001fd5b604051630a12f52160e11b8152600490fd5b8151158061021b575b6101f9575090565b604051639996b31560e01b81526001600160a01b039091166004820152602490fd5b50803b156101f156fe608060405273ffffffffffffffffffffffffffffffffffffffff7f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc54166000808092368280378136915af43d82803e156056573d90f35b3d90fdfea26469706673582212204bb00e6abd0a4bf00d571acc031008ab25c1d46bbe1f1dc947badf95fb540ab764736f6c63430008170033000000000000000000000000064de410ce7aba82396332c5837b4c6b96108283", + "deployedAddress": "0x7777777C2B3132e03a65721a41745C07170a5877", + "deploymentCaller": "0x680E26B472d8cae8148ee21FCAd6A69D73766436", + "salt": "0x680e26b472d8cae8148ee21fcad6a69d73766436cef59be84b94e84ff94848c2" +} \ No newline at end of file diff --git a/packages/comments/deterministicConfig/deployerAndCaller.json b/packages/comments/deterministicConfig/deployerAndCaller.json new file mode 120000 index 000000000..42c4490d4 --- /dev/null +++ b/packages/comments/deterministicConfig/deployerAndCaller.json @@ -0,0 +1 @@ +../../shared-contracts/deterministicConfig/deployerAndCaller.json \ No newline at end of file diff --git a/packages/comments/foundry.toml b/packages/comments/foundry.toml new file mode 100644 index 000000000..a22b11176 --- /dev/null +++ b/packages/comments/foundry.toml @@ -0,0 +1,24 @@ +[profile.default] +src = "src" +out = "out" +libs = ["node_modules"] +via_ir = true +solc_version = '0.8.23' +optimizer = true +optimizer_runs = 1_000_000 +fs_permissions = [ + { access = "readwrite", path = "./addresses" }, + { access = "read", path = "./package.json" }, + { access = "readwrite", path = "./deterministicConfig" }, + { access = "read", path = "../shared-contracts/chainConfigs" }, + { access = "readwrite", path = "../shared-contracts/deterministicConfig" } +] + +[profile.dev] +optimizer = false +optimizer_runs = 0 +via_ir = true + +[rpc_endpoints] +zora = "https://rpc.zora.energy/${CONDUIT_KEY}" +zora_sepolia = "https://sepolia.rpc.zora.energy/${CONDUIT_KEY}" \ No newline at end of file diff --git a/packages/comments/package.json b/packages/comments/package.json new file mode 100644 index 000000000..ba8214a29 --- /dev/null +++ b/packages/comments/package.json @@ -0,0 +1,63 @@ +{ + "name": "@zoralabs/comments-contracts", + "version": "0.0.2", + "author": "oveddan", + "license": "MIT", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.cjs" + } + }, + "scripts": { + "prettier:check": "prettier --check 'src/**/*.sol' 'test/**/*.sol' 'script/**/*.sol'", + "prettier:write": "prettier --write 'src/**/*.sol' 'test/**/*.sol' 'script/**/*.sol'", + "test": "forge test -vv", + "dev": "FOUNDRY_PROFILE=dev forge test --watch -vvv", + "test-gas": "forge test --gas-report", + "storage-inspect:check": "./script/storage-check.sh check ZoraTimedSaleStrategyImpl", + "storage-inspect:generate": "./script/storage-check.sh generate ZoraTimedSaleStrategyImpl", + "build:sizes": "forge build src/ --sizes", + "copy-abis": "pnpm tsx script/bundle-abis.ts", + "coverage": "FOUNDRY_PROFILE=default forge coverage --report lcov", + "build": "pnpm run wagmi:generate && pnpm run copy-abis && pnpm run prettier:write && tsup", + "wagmi:generate": "FOUNDRY_PROFILE=dev forge build && wagmi generate", + "update-contract-version": "pnpm tsx script/update-contract-version.ts" + }, + "devDependencies": { + "@openzeppelin/contracts": "5.0.2", + "@openzeppelin/contracts-upgradeable": "5.0.2", + "@turnkey/api-key-stamper": "^0.3.1", + "@turnkey/http": "^2.5.1", + "@turnkey/viem": "^0.4.4", + "@types/node": "^20.1.2", + "@wagmi/cli": "^1.0.1", + "@zoralabs/chains": "^1.3.1", + "@zoralabs/protocol-rewards": "workspace:^", + "@zoralabs/shared-contracts": "workspace:^", + "@zoralabs/sparks-contracts": "workspace:^", + "@zoralabs/tsconfig": "workspace:^", + "@zoralabs/zora-1155-contracts": "workspace:^", + "ds-test": "https://github.com/dapphub/ds-test#cd98eff28324bfac652e63a239a60632a761790b", + "forge-std": "https://github.com/foundry-rs/forge-std#v1.9.1", + "glob": "^10.2.2", + "pathe": "^1.1.2", + "prettier": "^3.0.3", + "prettier-plugin-solidity": "^1.3.1", + "solady": "0.0.132", + "tsup": "^7.2.0", + "tsx": "^3.13.0", + "typescript": "^5.2.2", + "viem": "^2.21.18" + }, + "dependencies": { + "abitype": "^1.0.2", + "dotenv": "^16.4.5" + } +} diff --git a/packages/comments/package/index.ts b/packages/comments/package/index.ts new file mode 100644 index 000000000..06da3c8bd --- /dev/null +++ b/packages/comments/package/index.ts @@ -0,0 +1,4 @@ +// the below files are auto-generated and will be +// built at build time. They are not checked in to git. +// The can be generated by running `yarn prepack` in the root +export * from "./wagmiGenerated"; diff --git a/packages/comments/package/types.ts b/packages/comments/package/types.ts new file mode 100644 index 000000000..f6ed4b89b --- /dev/null +++ b/packages/comments/package/types.ts @@ -0,0 +1,8 @@ +import { AbiParameterToPrimitiveType, ExtractAbiFunction } from "abitype"; +import { commentsImplABI } from "./wagmiGenerated"; +export type CommentIdentifier = AbiParameterToPrimitiveType< + ExtractAbiFunction< + typeof commentsImplABI, + "hashCommentIdentifier" + >["inputs"][0] +>; diff --git a/packages/comments/remappings.txt b/packages/comments/remappings.txt new file mode 100644 index 000000000..07dfad117 --- /dev/null +++ b/packages/comments/remappings.txt @@ -0,0 +1,8 @@ +ds-test/=node_modules/ds-test/src/ +forge-std/=node_modules/forge-std/src/ +@openzeppelin/=node_modules/@openzeppelin/ +@zoralabs/protocol-rewards/=node_modules/@zoralabs/protocol-rewards/ +@zoralabs/shared-contracts/=node_modules/@zoralabs/shared-contracts/src/ +@zoralabs/sparks-contracts/=node_modules/@zoralabs/sparks-contracts/src/ +@zoralabs/erc20z/=node_modules/@zoralabs/erc20z/src/ +solady/=node_modules/solady/src/ \ No newline at end of file diff --git a/packages/comments/script/AddDelegateCommenterRole.s.sol b/packages/comments/script/AddDelegateCommenterRole.s.sol new file mode 100644 index 000000000..d10460cec --- /dev/null +++ b/packages/comments/script/AddDelegateCommenterRole.s.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import "forge-std/Script.sol"; +import {CommentsDeployerBase} from "./CommentsDeployerBase.sol"; +import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; + +contract AddDelegateCommenterRole is CommentsDeployerBase { + function run() public view { + address owner = getProxyAdmin(); + bytes32 BACKFILLER_ROLE = keccak256("DELEGATE_COMMENTER"); + + address callerAndCommenter = readDeterministicContractConfig("callerAndCommenter").deployedAddress; + + address comments = readDeterministicContractConfig("comments").deployedAddress; + + bytes memory call = abi.encodeWithSelector(AccessControlUpgradeable.grantRole.selector, BACKFILLER_ROLE, callerAndCommenter); + + console.log("multisig", owner); + console.log("target", comments); + console.log("call:"); + console.logBytes(call); + } +} diff --git a/packages/comments/script/CommentsDeployerBase.sol b/packages/comments/script/CommentsDeployerBase.sol new file mode 100644 index 000000000..8bff2d6e6 --- /dev/null +++ b/packages/comments/script/CommentsDeployerBase.sol @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import "forge-std/Script.sol"; + +import {ProxyDeployerScript, DeterministicContractConfig, DeterministicDeployerAndCaller} from "@zoralabs/shared-contracts/deployment/ProxyDeployerScript.sol"; +import {CommentsImpl} from "../src/CommentsImpl.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {CallerAndCommenterImpl} from "../src/utils/CallerAndCommenterImpl.sol"; +import {CallerAndCommenter} from "../src/proxy/CallerAndCommenter.sol"; + +// Temp script +contract CommentsDeployerBase is ProxyDeployerScript { + uint256 internal constant SPARK_VALUE = 0.000001 ether; + address internal constant PROTOCOL_REWARDS = 0x7777777F279eba3d3Ad8F4E708545291A6fDBA8B; + address internal constant ZORA_TIMED_SALE_STRATEGY = 0x777777722D078c97c6ad07d9f36801e653E356Ae; + address internal constant SECONDARY_SWAP = 0x777777EDF27Ac61671e3D5718b10bf6a8802f9f1; + + using stdJson for string; + + struct CommentsDeployment { + address comments; + address commentsImpl; + string commentsVersion; + address callerAndCommenter; + address callerAndCommenterImpl; + string callerAndCommenterVersion; + uint256 commentsBlockNumber; + uint256 commentsImplBlockNumber; + } + + function addressesFile() internal view returns (string memory) { + return string.concat("./addresses/", vm.toString(block.chainid), ".json"); + } + + function saveDeployment(CommentsDeployment memory deployment) internal { + string memory objectKey = "config"; + + vm.serializeAddress(objectKey, "COMMENTS", deployment.comments); + vm.serializeUint(objectKey, "COMMENTS_BLOCK_NUMBER", deployment.commentsBlockNumber); + vm.serializeAddress(objectKey, "COMMENTS_IMPL", deployment.commentsImpl); + vm.serializeAddress(objectKey, "CALLER_AND_COMMENTER", deployment.callerAndCommenter); + vm.serializeAddress(objectKey, "CALLER_AND_COMMENTER_IMPL", deployment.callerAndCommenterImpl); + vm.serializeString(objectKey, "CALLER_AND_COMMENTER_VERSION", deployment.callerAndCommenterVersion); + string memory result = vm.serializeUint(objectKey, "COMMENTS_IMPL_BLOCK_NUMBER", deployment.commentsImplBlockNumber); + + vm.writeJson(result, addressesFile()); + } + + function readDeployment() internal returns (CommentsDeployment memory deployment) { + string memory file = addressesFile(); + if (!vm.exists(file)) { + return deployment; + } + string memory json = vm.readFile(file); + + deployment.comments = readAddressOrDefaultToZero(json, "COMMENTS"); + deployment.commentsImpl = readAddressOrDefaultToZero(json, "COMMENTS_IMPL"); + deployment.commentsVersion = readStringOrDefaultToEmpty(json, "COMMENTS_VERSION"); + deployment.commentsBlockNumber = readUintOrDefaultToZero(json, "COMMENTS_BLOCK_NUMBER"); + deployment.commentsImplBlockNumber = readUintOrDefaultToZero(json, "COMMENTS_IMPL_BLOCK_NUMBER"); + deployment.callerAndCommenter = readAddressOrDefaultToZero(json, "CALLER_AND_COMMENTER"); + deployment.callerAndCommenterImpl = readAddressOrDefaultToZero(json, "CALLER_AND_COMMENTER_IMPL"); + deployment.callerAndCommenterVersion = readStringOrDefaultToEmpty(json, "CALLER_AND_COMMENTER_VERSION"); + } + + function commentsImplCreationCode() internal pure returns (bytes memory) { + return abi.encodePacked(type(CommentsImpl).creationCode, abi.encode(SPARK_VALUE, PROTOCOL_REWARDS)); + } + + function getBackfillerAccount() internal pure returns (address) { + return 0x77baCD258d2E6A5187B7344419A5e2842A49A059; + } + + function deployCommentsImpl() internal returns (CommentsImpl) { + return new CommentsImpl(SPARK_VALUE, PROTOCOL_REWARDS, getZoraRecipient()); + } + + function deployCommentsDeterministic(CommentsDeployment memory deployment, DeterministicDeployerAndCaller deployer) internal { + // read previously saved deterministic royalties config + DeterministicContractConfig memory commentsConfig = readDeterministicContractConfig("comments"); + DeterministicContractConfig memory callerAndCommenterConfig = readDeterministicContractConfig("callerAndCommenter"); + + address backfiller = CommentsDeployerBase.getBackfillerAccount(); + // get deployed implementation address. it it's not deployed, revert + address implAddress = address(deployCommentsImpl()); + + if (implAddress.code.length == 0) { + revert("Impl not yet deployed. Make sure to deploy it with DeployImpl.s.sol"); + } + + address[] memory delegateCommenters = new address[](1); + delegateCommenters[0] = callerAndCommenterConfig.deployedAddress; + + // build upgrade to and call for comments, with init call + bytes memory upgradeToAndCall = abi.encodeWithSelector( + UUPSUpgradeable.upgradeToAndCall.selector, + implAddress, + abi.encodeWithSelector(CommentsImpl.initialize.selector, getProxyAdmin(), backfiller, delegateCommenters) + ); + + // sign royalties deployment with turnkey account + bytes memory signature = signDeploymentWithTurnkey(commentsConfig, upgradeToAndCall, deployer); + + deployment.comments = deployer.permitSafeCreate2AndCall( + signature, + commentsConfig.salt, + commentsConfig.creationCode, + upgradeToAndCall, + commentsConfig.deployedAddress + ); + deployment.commentsBlockNumber = block.number; + } + + function deployCallerAndCommenterImpl(address commentsAddress) internal returns (address) { + return address(new CallerAndCommenterImpl(commentsAddress, ZORA_TIMED_SALE_STRATEGY, SECONDARY_SWAP, SPARK_VALUE)); + } + + function deployCallerAndCommenterDeterministic(CommentsDeployment memory deployment, DeterministicDeployerAndCaller deployer) internal { + address commentsAddress = readDeterministicContractConfig("comments").deployedAddress; + DeterministicContractConfig memory callerAndCommenterConfig = readDeterministicContractConfig("callerAndCommenter"); + + // deploy caller and commenter impl + deployment.callerAndCommenterImpl = address(deployCallerAndCommenterImpl(commentsAddress)); + deployment.callerAndCommenterVersion = CallerAndCommenterImpl(deployment.callerAndCommenterImpl).contractVersion(); + + bytes memory upgradeToAndCall = abi.encodeWithSelector( + UUPSUpgradeable.upgradeToAndCall.selector, + deployment.callerAndCommenterImpl, + abi.encodeWithSelector(CallerAndCommenterImpl.initialize.selector, getProxyAdmin()) + ); + + bytes memory signature = signDeploymentWithTurnkey(callerAndCommenterConfig, upgradeToAndCall, deployer); + + deployment.callerAndCommenter = deployer.permitSafeCreate2AndCall( + signature, + callerAndCommenterConfig.salt, + callerAndCommenterConfig.creationCode, + upgradeToAndCall, + callerAndCommenterConfig.deployedAddress + ); + } +} diff --git a/packages/comments/script/Deploy.s.sol b/packages/comments/script/Deploy.s.sol new file mode 100644 index 000000000..3c39d7cf9 --- /dev/null +++ b/packages/comments/script/Deploy.s.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {ProxyDeployerScript, DeterministicDeployerAndCaller, DeterministicContractConfig} from "@zoralabs/shared-contracts/deployment/ProxyDeployerScript.sol"; +import {CommentsDeployerBase} from "./CommentsDeployerBase.sol"; + +contract DeployScript is CommentsDeployerBase { + function run() public { + CommentsDeployment memory deployment = readDeployment(); + + vm.startBroadcast(); + + // get deployer contract + DeterministicDeployerAndCaller deployer = createOrGetDeployerAndCaller(); + + deployCommentsDeterministic(deployment, deployer); + deployCallerAndCommenterDeterministic(deployment, deployer); + + vm.stopBroadcast(); + + // save the deployment json + saveDeployment(deployment); + } +} diff --git a/packages/comments/script/DeployCallerAndCommenterImpl.s.sol b/packages/comments/script/DeployCallerAndCommenterImpl.s.sol new file mode 100644 index 000000000..56e8c0959 --- /dev/null +++ b/packages/comments/script/DeployCallerAndCommenterImpl.s.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "forge-std/console2.sol"; + +import {CommentsDeployerBase} from "./CommentsDeployerBase.sol"; +import {CallerAndCommenterImpl} from "../src/utils/CallerAndCommenterImpl.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; + +contract DeployCallerAndCommenterImpl is CommentsDeployerBase { + function run() public { + CommentsDeployment memory config = readDeployment(); + + vm.startBroadcast(); + + config.callerAndCommenterImpl = deployCallerAndCommenterImpl(config.comments); + config.callerAndCommenterVersion = CallerAndCommenterImpl(config.callerAndCommenterImpl).contractVersion(); + + vm.stopBroadcast(); + + console2.log("CallerAndCommenterImpl deployed, to upgrade:"); + console2.log("target:", config.callerAndCommenter); + console2.log("calldata:"); + console2.logBytes(abi.encodeWithSelector(UUPSUpgradeable.upgradeToAndCall.selector, config.callerAndCommenterImpl, "")); + console2.log("multisig:", getProxyAdmin()); + + saveDeployment(config); + } +} diff --git a/packages/comments/script/DeployImpl.s.sol b/packages/comments/script/DeployImpl.s.sol new file mode 100644 index 000000000..c71f99d17 --- /dev/null +++ b/packages/comments/script/DeployImpl.s.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "forge-std/console2.sol"; + +import {CommentsDeployerBase} from "./CommentsDeployerBase.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; + +contract DeployImpl is CommentsDeployerBase { + function run() public { + CommentsDeployment memory config = readDeployment(); + vm.startBroadcast(); + + config.commentsImpl = address(deployCommentsImpl()); + config.commentsImplBlockNumber = block.number; + + vm.stopBroadcast(); + + console2.log("CommentsImpl deployed, to upgrade:"); + console2.log("target:", config.comments); + console2.log("calldata:"); + console2.logBytes(abi.encodeWithSelector(UUPSUpgradeable.upgradeToAndCall.selector, config.commentsImpl, "")); + console2.log("multisig:", getProxyAdmin()); + + saveDeployment(config); + } +} diff --git a/packages/comments/script/DeployNonDeterministic.s.sol b/packages/comments/script/DeployNonDeterministic.s.sol new file mode 100644 index 000000000..e35b3ebf8 --- /dev/null +++ b/packages/comments/script/DeployNonDeterministic.s.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "forge-std/Script.sol"; +import {Comments} from "../src/proxy/Comments.sol"; +import {CommentsImpl} from "../src/CommentsImpl.sol"; +import {CommentsDeployerBase} from "./CommentsDeployerBase.sol"; +import {CallerAndCommenterImpl} from "../src/utils/CallerAndCommenterImpl.sol"; +import {CallerAndCommenter} from "../src/proxy/CallerAndCommenter.sol"; + +contract DeployNonDeterministic is CommentsDeployerBase { + function run() public { + CommentsDeployment memory deployment = readDeployment(); + + address owner = getProxyAdmin(); + + address backfiller = CommentsDeployerBase.getBackfillerAccount(); + + vm.startBroadcast(); + + address implAddress = deployment.commentsImpl; + + if (implAddress.code.length == 0) { + revert("impl not deployed"); + } + + Comments comments = new Comments(implAddress); + + CallerAndCommenterImpl callerAndCommenterImpl = new CallerAndCommenterImpl(address(comments), ZORA_TIMED_SALE_STRATEGY, SECONDARY_SWAP, SPARK_VALUE); + + CallerAndCommenter callerAndCommenter = new CallerAndCommenter(address(callerAndCommenterImpl)); + + CallerAndCommenterImpl(payable(address(callerAndCommenter))).initialize(owner); + + address[] memory delegateCommenters = new address[](1); + delegateCommenters[0] = address(callerAndCommenter); + + CommentsImpl(payable(address(comments))).initialize(owner, backfiller, delegateCommenters); + + vm.stopBroadcast(); + + deployment.comments = address(comments); + deployment.commentsBlockNumber = block.number; + deployment.callerAndCommenter = address(callerAndCommenter); + deployment.callerAndCommenterImpl = address(callerAndCommenterImpl); + + // save the deployment json + saveDeployment(deployment); + + console.log("Comments deployed at", address(comments)); + } +} diff --git a/packages/comments/script/GenerateDeterministicParams.s.sol b/packages/comments/script/GenerateDeterministicParams.s.sol new file mode 100644 index 000000000..82fc2cb21 --- /dev/null +++ b/packages/comments/script/GenerateDeterministicParams.s.sol @@ -0,0 +1,83 @@ +// spdx-license-identifier: mit +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; + +import {ImmutableCreate2FactoryUtils} from "@zoralabs/shared-contracts/utils/ImmutableCreate2FactoryUtils.sol"; +import {ProxyDeployerScript, DeterministicDeployerAndCaller, DeterministicContractConfig} from "@zoralabs/shared-contracts/deployment/ProxyDeployerScript.sol"; +import {Comments} from "../src/proxy/Comments.sol"; +import {CallerAndCommenter} from "../src/proxy/CallerAndCommenter.sol"; + +/// @dev This script saves the current bytecode, and initialization parameters for the Sparks proxy, +/// which then need to be populated with a salt and expected address, which can be achieved by +/// running the printed create2crunch command. The resulting config only needs to be generated once +/// and is reusable for all chains. +contract GenerateDeterministicParams is ProxyDeployerScript { + function mineForCommentsAddress(DeterministicDeployerAndCaller deployer, address caller) private returns (DeterministicContractConfig memory config) { + // get proxy creation code + // get the expected init code for the proxy from the uupsProxyDeployer + bytes memory initCode = deployer.proxyCreationCode(type(Comments).creationCode); + bytes32 initCodeHash = keccak256(initCode); + + // uupsProxyDeployer is deployer + (bytes32 salt, address expectedAddress) = mineSalt(address(deployer), initCodeHash, "7777777", caller); + + // test deployment + // Create2.deploy(0, salt, initCode); + + console2.log("salt"); + console2.log(vm.toString(salt)); + + config.salt = salt; + config.deployedAddress = expectedAddress; + config.creationCode = initCode; + config.constructorArgs = deployer.proxyConstructorArgs(); + config.contractName = "Comments"; + config.deploymentCaller = caller; + } + + function mineForCallerAndCommenterAddress( + DeterministicDeployerAndCaller deployer, + address caller + ) private returns (DeterministicContractConfig memory config) { + // get proxy creation code + // get the expected init code for the proxy from the uupsProxyDeployer + bytes memory initCode = deployer.proxyCreationCode(type(CallerAndCommenter).creationCode); + bytes32 initCodeHash = keccak256(initCode); + + // uupsProxyDeployer is deployer + (bytes32 salt, address expectedAddress) = mineSalt(address(deployer), initCodeHash, "7777777", caller); + + // test deployment + // Create2.deploy(0, salt, initCode); + + console2.log("salt"); + console2.log(vm.toString(salt)); + + config.salt = salt; + config.deployedAddress = expectedAddress; + config.creationCode = initCode; + config.constructorArgs = deployer.proxyConstructorArgs(); + config.contractName = "CallerAndCommenter"; + config.deploymentCaller = caller; + } + + function run() public { + address caller = vm.envAddress("DEPLOYER"); + + vm.startBroadcast(); + + // create a proxy deployer, which we can use to generated determistic addresses and corresponding params. + // proxy deployer code is based on code saved to file from running the script SaveProxyDeployerConfig.s.sol + DeterministicDeployerAndCaller deployer = createOrGetDeployerAndCaller(); + + vm.stopBroadcast(); + + // DeterministicContractConfig memory commentsConfig = mineForCommentsAddress(deployer, caller); + + DeterministicContractConfig memory callerAndCommenterConfig = mineForCallerAndCommenterAddress(deployer, caller); + + // saveDeterministicContractConfig(commentsConfig, "comments"); + saveDeterministicContractConfig(callerAndCommenterConfig, "callerAndCommenter"); + } +} diff --git a/packages/comments/script/bundle-abis.ts b/packages/comments/script/bundle-abis.ts new file mode 100644 index 000000000..9834270a1 --- /dev/null +++ b/packages/comments/script/bundle-abis.ts @@ -0,0 +1,109 @@ +import { promises as fs } from "fs"; +import { basename, extname, join, resolve } from "pathe"; +import { glob } from "glob"; +import { ContractConfig } from "@wagmi/cli"; + +const defaultExcludes = [ + "Common.sol/**", + "Components.sol/**", + "Script.sol/**", + "StdAssertions.sol/**", + "StdInvariant.sol/**", + "StdError.sol/**", + "StdCheats.sol/**", + "StdMath.sol/**", + "StdJson.sol/**", + "StdStorage.sol/**", + "StdUtils.sol/**", + "Vm.sol/**", + "console.sol/**", + "console2.sol/**", + "test.sol/**", + "**.s.sol/*.json", + "**.t.sol/*.json", +]; + +// design inspired by https://github.com/wagmi-dev/wagmi/blob/main/packages/cli/src/plugins/foundry.ts + +export const readContracts = async ({ + deployments = {} as any, + exclude = defaultExcludes, + include = ["*.json"], + namePrefix = "", + project_ = "./", +}) => { + // get all the files in ./out + function getContractName(artifactPath: string, usePrefix = true) { + const filename = basename(artifactPath); + const extension = extname(artifactPath); + return `${usePrefix ? namePrefix : ""}${filename.replace(extension, "")}`; + } + + async function getContract(artifactPath: string) { + const artifact = JSON.parse(await fs.readFile(artifactPath, "utf-8")); + return { + abi: artifact.abi, + address: (deployments as Record)[ + getContractName(artifactPath, false) + ], + name: getContractName(artifactPath), + }; + } + + async function getArtifactPaths(artifactsDirectory: string) { + return await glob([ + ...include.map((x) => `${artifactsDirectory}/**/${x}`), + ...exclude.map((x) => `!${artifactsDirectory}/**/${x}`), + ]); + } + + const project = resolve(process.cwd(), project_ ?? ""); + + const config = { + out: "out", + src: "src", + }; + + const artifactsDirectory = join(project, config.out); + + const artifactPaths = await getArtifactPaths(artifactsDirectory); + const contracts = []; + for (const artifactPath of artifactPaths) { + const contract = await getContract(artifactPath); + if (!contract.abi?.length) continue; + contracts.push(contract); + } + return contracts; +}; + +async function saveContractsAbisJson(contracts: { abi: any; name: string }[]) { + // for each contract, write abi to ./abis/{contractName}.json + + const abisFolder = "./abis"; + + // mkdir - p ./abis: + await fs.mkdir(abisFolder, { recursive: true }); + // remove abis folder: + await fs.rm(abisFolder, { recursive: true }); + // add it back + // mkdir - p ./abis: + await fs.mkdir(abisFolder, { recursive: true }); + + // now write abis: + await Promise.all( + contracts.map(async (contract) => { + const abiJson = JSON.stringify(contract.abi, null, 2); + const abiJsonPath = `${abisFolder}/${contract.name}.json`; + + await fs.writeFile(abiJsonPath, abiJson); + }), + ); +} + +async function main() { + const contracts = await readContracts({}); + + await saveContractsAbisJson(contracts); +} + +main(); diff --git a/packages/comments/script/storage-check.sh b/packages/comments/script/storage-check.sh new file mode 100755 index 000000000..a38fb5e81 --- /dev/null +++ b/packages/comments/script/storage-check.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash + +set -e + +generate() { + file=$1 + if [[ $func == "generate" ]]; then + echo "Creating storage layout diagrams for the following contracts: $contracts" + echo "..." + fi + + echo "=======================" > "$file" + echo "👁👁 STORAGE LAYOUT snapshot 👁👁" >"$file" + echo "=======================" >> "$file" +# shellcheck disable=SC2068 + for contract in ${contracts[@]} + do + { echo -e "\n======================="; echo "➡ $contract" ; echo -e "=======================\n"; } >> "$file" + FOUNDRY_PROFILE=dev forge inspect --pretty "$contract" storage-layout >> "$file" + done + if [[ $func == "generate" ]]; then + echo "Storage layout snapshot stored at $file" + fi +} + +if ! command -v forge &> /dev/null +then + echo "forge could not be found. Please install forge by running:" + echo "curl -L https://foundry.paradigm.xyz | bash" + exit +fi + +# shellcheck disable=SC2124 +contracts="${@:2}" +func=$1 +filename=.storage-layout +new_filename=.storage-layout.temp + +if [[ $func == "check" ]]; then + generate $new_filename + if ! cmp -s .storage-layout $new_filename ; then + echo "storage-layout test: fails ❌" + echo "The following lines are different:" + diff -a --suppress-common-lines "$filename" "$new_filename" + rm $new_filename + exit 1 + else + echo "storage-layout test: passes ✅" + rm $new_filename + exit 0 + fi +elif [[ $func == "generate" ]]; then + generate "$filename" +else + echo "unknown command. Use 'generate' or 'check' as the first argument." + exit 1 +fi diff --git a/packages/comments/script/update-contract-version.ts b/packages/comments/script/update-contract-version.ts new file mode 100644 index 000000000..bc3d7ca63 --- /dev/null +++ b/packages/comments/script/update-contract-version.ts @@ -0,0 +1,63 @@ +import fs from "fs"; +import path from "path"; +import { promisify } from "util"; +import { fileURLToPath } from "url"; + +const readFileAsync = promisify(fs.readFile); +const writeFileAsync = promisify(fs.writeFile); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const makePackageVersionFile = async (version: string): Promise => { + console.log("updating contract version to ", version); + // read the version from the root package.json: + + const packageVersionCode = `// This file is automatically generated by code; do not manually update +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import {IVersionedContract} from "@zoralabs/shared-contracts/interfaces/IVersionedContract.sol"; + +/// @title ContractVersionBase +/// @notice Base contract for versioning contracts +contract ContractVersionBase is IVersionedContract { + /// @notice The version of the contract + function contractVersion() external pure override returns (string memory) { + return "${version}"; + } +} +`; + + // write the file to __dirname__/../src/version/ContractVersionBase.sol: + const filePath = path.join( + __dirname, + "..", + "src", + "version", + "ContractVersionBase.sol", + ); + + console.log("generated contract version code:", packageVersionCode); + console.log("writing file to", filePath); + + await writeFileAsync(filePath, packageVersionCode); +}; + +const getVersion = async (): Promise => { + // read package.json file, parse json, then get version: + const packageJson = JSON.parse( + await readFileAsync(path.join(__dirname, "..", "package.json"), "utf-8"), + ); + + return packageJson.version; +}; + +const main = async (): Promise => { + const version = await getVersion(); + await makePackageVersionFile(version); +}; + +main().catch((error) => { + console.error("Error updating contract version:", error); +}); diff --git a/packages/comments/scripts/abis.ts b/packages/comments/scripts/abis.ts new file mode 100644 index 000000000..2aef6361c --- /dev/null +++ b/packages/comments/scripts/abis.ts @@ -0,0 +1,3 @@ +export const zoraTimedSaleStrategyImplABI = [ + "function mint(address mintTo, uint256 quantity, address collection, uint256 tokenId, address mintReferral, string calldata comment) external returns (uint256)", +] as const; diff --git a/packages/comments/scripts/generateCommentsTestData.ts b/packages/comments/scripts/generateCommentsTestData.ts new file mode 100644 index 000000000..a1faf64b2 --- /dev/null +++ b/packages/comments/scripts/generateCommentsTestData.ts @@ -0,0 +1,338 @@ +import { + parseEther, + PublicClient, + Address, + WalletClient, + Hex, + Chain, + Transport, + numberToHex, + zeroAddress, + keccak256, + Account, + TransactionReceipt, + parseEventLogs, +} from "viem"; +import { zoraCreator1155ImplABI } from "@zoralabs/zora-1155-contracts"; +import { privateKeyToAccount } from "viem/accounts"; +import { zoraTimedSaleStrategyImplABI } from "./abis"; +import { zoraSepolia } from "viem/chains"; +import { CommentIdentifier } from "../package/types"; +import { getCommentsAddress } from "./getCommentsAddresses"; +import { commentsImplABI } from "../package/wagmiGenerated"; +import { getChainConfig } from "./utils"; +// load env variables +import dotenv from "dotenv"; +dotenv.config(); + +const MINT_FEE = parseEther("0.000111"); +const SPARK_VALUE = parseEther("0.000001"); +const TEST_1155_CONTRACT = "0xD42557F24034b53e7340A40bb5813eF9Ba88F2b4"; +const TEST_TOKEN_ID = 3n; +const ZORA_TIMED_SALE_STRATEGY = "0x777777722D078c97c6ad07d9f36801e653E356Ae"; +const GAS_FEE = parseEther("0.000001"); + +const getAccountFromEnv = ({ keyName }: { keyName: string }) => { + const privateKey = process.env[keyName] as Address; + if (!privateKey) { + throw new Error(`${keyName} not found in environment`); + } + return privateKeyToAccount(privateKey); +}; + +const getCommentIdentifierFromReceipt = ({ + receipt, +}: { + receipt: TransactionReceipt; +}): CommentIdentifier => { + const logs = parseEventLogs({ + abi: commentsImplABI, + logs: receipt.logs, + eventName: "Commented", + }); + + if (logs.length === 0) { + throw new Error("No Commented event found in receipt"); + } + + return logs[0]!.args.commentIdentifier; +}; + +const waitForReceiptAndEnsureSuccess = async ({ + hash, + publicClient, +}: { + hash: Hex; + publicClient: PublicClient; +}) => { + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + if (receipt.status !== "success") { + throw new Error("Transaction failed"); + } + + return receipt; +}; + +const mintIfNotOwner = async ({ + walletClient, + publicClient, + account, + commenter, +}: { + walletClient: WalletClient; + publicClient: PublicClient; + account: Account; + commenter: Account; +}) => { + // check if commenter has a mint on 1155 + const balance = await publicClient.readContract({ + abi: zoraCreator1155ImplABI, + address: TEST_1155_CONTRACT, + functionName: "balanceOf", + args: [commenter.address, TEST_TOKEN_ID], + }); + + if (balance === 0n) { + console.log("minting token to commenter"); + + const tx = await walletClient.writeContract({ + abi: zoraTimedSaleStrategyImplABI, + address: ZORA_TIMED_SALE_STRATEGY, + functionName: "mint", + account, + args: [ + commenter.address, + 1n, + TEST_1155_CONTRACT, + TEST_TOKEN_ID, + zeroAddress, + "", + ], + value: MINT_FEE, + }); + + await waitForReceiptAndEnsureSuccess({ hash: tx, publicClient }); + } +}; + +const generateTestComments = async ({ + walletClient, + publicClient, + commentsAddress, + account, + commenter, + contractAddress, + tokenId, +}: { + walletClient: WalletClient; + publicClient: PublicClient; + commentsAddress: Address; + account: Account; + commenter: Account; + contractAddress: Address; + tokenId: bigint; +}) => { + await mintIfNotOwner({ walletClient, publicClient, account, commenter }); + + const emptyCommentIdentifier: CommentIdentifier = { + commenter: zeroAddress, + contractAddress: zeroAddress, + tokenId: 0n, + nonce: keccak256(numberToHex(0)), + } as const; + + console.log("sending 2 sparks worth of eth to commenter"); + + // send 2 sparks worth of eth to commenter + let tx = await walletClient.sendTransaction({ + account, + to: commenter.address, + value: (SPARK_VALUE + GAS_FEE) * 2n, + }); + await waitForReceiptAndEnsureSuccess({ hash: tx, publicClient }); + + console.log("commenting"); + + const commentIdentifier = await writeCommentToContract({ + walletClient, + publicClient, + commentsAddress, + commenter, + contractAddress, + tokenId, + text: "This is a test comment", + replyTo: emptyCommentIdentifier, + }); + + console.log("replying to comment"); + + const replyCommentIdentifier = await writeCommentToContract({ + walletClient, + publicClient, + commentsAddress, + commenter, + contractAddress, + tokenId, + text: "This is a test reply", + replyTo: commentIdentifier, + }); + + return { commentIdentifier, replyCommentIdentifier }; +}; + +const writeCommentToContract = async ({ + walletClient, + publicClient, + commentsAddress, + commenter, + contractAddress, + tokenId, + text, + replyTo, +}: { + walletClient: WalletClient; + publicClient: PublicClient; + commentsAddress: Address; + commenter: Account | Address; + contractAddress: Address; + tokenId: bigint; + text: string; + replyTo: CommentIdentifier; +}): Promise => { + const tx = await walletClient.writeContract({ + abi: commentsImplABI, + address: commentsAddress, + functionName: "comment", + account: commenter, + args: [ + typeof commenter === "string" ? commenter : commenter.address, + contractAddress, + tokenId, + text, + replyTo, + zeroAddress, + zeroAddress, + ], + value: SPARK_VALUE, + }); + + const receipt = await waitForReceiptAndEnsureSuccess({ + hash: tx, + publicClient, + }); + + return getCommentIdentifierFromReceipt({ receipt }); +}; + +const sendSparksEth = async ({ + walletClient, + publicClient, + account, + recipient, + value, +}: { + walletClient: WalletClient; + publicClient: PublicClient; + account: Account; + recipient: Address; + value: bigint; +}) => { + console.log("sending sparks worth of eth to recipient + gas"); + const tx = await walletClient.sendTransaction({ + account, + to: recipient, + value, + }); + await waitForReceiptAndEnsureSuccess({ hash: tx, publicClient }); +}; + +const sparkComment = async ({ + walletClient, + publicClient, + commentsAddress, + account, + sparker, + commentIdentifier, +}: { + walletClient: WalletClient; + publicClient: PublicClient; + commentsAddress: Address; + account: Account; + sparker: Account; + commentIdentifier: CommentIdentifier; +}) => { + await sendSparksEth({ + walletClient, + publicClient, + account, + recipient: sparker.address, + value: SPARK_VALUE + GAS_FEE, + }); + + console.log("sparking comment"); + + // spark the comment + const tx = await walletClient.writeContract({ + abi: commentsImplABI, + address: commentsAddress, + functionName: "sparkComment", + account: sparker, + args: [commentIdentifier, 1n, zeroAddress], + value: SPARK_VALUE, + }); + + await waitForReceiptAndEnsureSuccess({ hash: tx, publicClient }); +}; + +export const generateCommentsTestData = async () => { + const { walletClient, publicClient } = await getChainConfig("zora-sepolia"); + + const commentsAddress = (await getCommentsAddress(zoraSepolia.id)).COMMENTS; + + const account = getAccountFromEnv({ keyName: "PRIVATE_KEY" }); + + const commenter = getAccountFromEnv({ keyName: "COMMENTER_PRIVATE_KEY" }); + const sparker = getAccountFromEnv({ keyName: "SPARKER_PRIVATE_KEY" }); + + const { commentIdentifier, replyCommentIdentifier } = + await generateTestComments({ + walletClient, + publicClient, + commentsAddress, + account, + commenter, + contractAddress: TEST_1155_CONTRACT, + tokenId: TEST_TOKEN_ID, + }); + + // spark the comment twice + await sparkComment({ + walletClient, + publicClient, + commentsAddress, + account, + sparker, + commentIdentifier, + }); + + await sparkComment({ + walletClient, + publicClient, + commentsAddress, + account, + sparker, + commentIdentifier, + }); + + // spark the reply comment + await sparkComment({ + walletClient, + publicClient, + commentsAddress, + account, + sparker, + commentIdentifier: replyCommentIdentifier, + }); +}; + +generateCommentsTestData(); diff --git a/packages/comments/scripts/getCommentsAddresses.ts b/packages/comments/scripts/getCommentsAddresses.ts new file mode 100644 index 000000000..093f19c64 --- /dev/null +++ b/packages/comments/scripts/getCommentsAddresses.ts @@ -0,0 +1,10 @@ +import { Address } from "viem"; +import { readFile } from "fs/promises"; + +export const getCommentsAddress = async (chainId: number) => { + const addresses = await readFile(`./addresses/${chainId}.json`, "utf8"); + + return JSON.parse(addresses) as { + COMMENTS: Address; + }; +}; diff --git a/packages/comments/scripts/signDeployAndCall.ts b/packages/comments/scripts/signDeployAndCall.ts new file mode 100644 index 000000000..7b03708f4 --- /dev/null +++ b/packages/comments/scripts/signDeployAndCall.ts @@ -0,0 +1,51 @@ +import { Hex, Address } from "viem"; +import { loadTurnkeyAccount } from "./turnkey"; + +const loadParameters = () => { + const [, , chainId, salt, creationCode, init, deployerAddress] = process.argv; + + return { + chainId: +chainId, + salt: salt as Hex, + creationCode: creationCode as Hex, + init: init as Hex, + deployerAddress: deployerAddress as Address, + }; +}; + +/// Deploy the mints manager and 1155 contract deteriministically using turnkey +async function main() { + const parameters = loadParameters(); + + const turnkeyAccount = await loadTurnkeyAccount(); + + // "create(bytes32 salt,bytes code,bytes postCreateCall,uint256 postCreateCallValue)"); + const signature = await turnkeyAccount.signTypedData({ + types: { + create: [ + { name: "salt", type: "bytes32" }, + { name: "code", type: "bytes" }, + { name: "postCreateCall", type: "bytes" }, + ], + }, + primaryType: "create", + message: { + code: parameters.creationCode, + salt: parameters.salt, + postCreateCall: parameters.init, + }, + domain: { + chainId: parameters.chainId, + name: "DeterministicDeployerAndCaller", + version: "1", + verifyingContract: parameters.deployerAddress, + }, + }); + + console.log(signature); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/packages/comments/scripts/turnkey.ts b/packages/comments/scripts/turnkey.ts new file mode 100644 index 000000000..5a048a52d --- /dev/null +++ b/packages/comments/scripts/turnkey.ts @@ -0,0 +1,36 @@ +import { TurnkeyClient } from "@turnkey/http"; +import { ApiKeyStamper } from "@turnkey/api-key-stamper"; +import { createAccount } from "@turnkey/viem"; + +import { fileURLToPath } from "url"; +import { dirname } from "path"; +import * as path from "path"; +import * as dotenv from "dotenv"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +dotenv.config({ path: path.resolve(__dirname, "../.env") }); + +export const loadTurnkeyAccount = async () => { + const httpClient = new TurnkeyClient( + { + baseUrl: "https://api.turnkey.com", + }, + // This uses API key credentials. + // If you're using passkeys, use `@turnkey/webauthn-stamper` to collect webauthn signatures: + new ApiKeyStamper({ + apiPublicKey: process.env.TURNKEY_API_PUBLIC_KEY!, + apiPrivateKey: process.env.TURNKEY_API_PRIVATE_KEY!, + }), + ); + + // Create the Viem custom account + return await createAccount({ + client: httpClient, + organizationId: process.env.TURNKEY_ORGANIZATION_ID!, + signWith: process.env.TURNKEY_PRIVATE_KEY_ID!, + // optional; will be fetched from Turnkey if not provided + ethereumAddress: process.env.TURNKEY_TARGET_ADDRESS!, + }); +}; diff --git a/packages/comments/scripts/utils.ts b/packages/comments/scripts/utils.ts new file mode 100644 index 000000000..bedca9f73 --- /dev/null +++ b/packages/comments/scripts/utils.ts @@ -0,0 +1,127 @@ +import { + createPublicClient, + http, + Chain, + createWalletClient, + PublicClient, + WalletClient, + Transport, +} from "viem"; +import * as chains from "viem/chains"; +import { getChain } from "@zoralabs/chains"; + +export const makeClientsForChain = async ( + chainName: string, +): Promise<{ + publicClient: PublicClient; + walletClient: WalletClient; + chainId: number; +}> => { + const configuredChain = await getChain(chainName); + + if (configuredChain.id === 9999999) { + configuredChain.id = chains.zoraSepolia.id; + } + + if (!configuredChain) { + throw new Error(`No chain config found for chain name ${chainName}`); + } + + const chainConfig = Object.values(chains).find( + (x) => x.id === configuredChain.id, + ); + + if (!chainConfig) { + throw new Error(`No chain config found for chain id ${configuredChain.id}`); + } + + const rpcUrl = configuredChain.rpcUrl; + + if (!rpcUrl) { + throw new Error(`No RPC found for chain id ${configuredChain.id}`); + } + + return { + publicClient: createPublicClient({ + transport: http(), + chain: { + ...chainConfig, + rpcUrls: { + default: { + http: [rpcUrl], + }, + public: { + http: [rpcUrl], + }, + }, + }, + }) as PublicClient, + walletClient: createWalletClient({ + transport: http(), + chain: { + ...chainConfig, + rpcUrls: { + default: { + http: [rpcUrl], + }, + public: { + http: [rpcUrl], + }, + }, + }, + }), + chainId: configuredChain.id as number, + }; +}; + +export const makeWalletClientForChain = async (chainName: string) => { + const configuredChain = await getChain(chainName); + + if (configuredChain.id === 9999999) { + configuredChain.id = chains.zoraSepolia.id; + } +}; +export function getChainNamePositionalArg() { + // parse chain id as first argument: + const chainName = process.argv[2]; + + if (!chainName) { + throw new Error("Must provide chain name as first argument"); + } + + return chainName; +} + +const CONFIG_BASE = + "https://api.goldsky.com/api/public/project_clhk16b61ay9t49vm6ntn4mkz/subgraphs"; + +export function getSubgraph(name: string, version: string = "stable"): string { + return `${CONFIG_BASE}/${name}/${version}/gn`; +} + +const subgraphChainName = (chainName: string) => { + if (chainName === "zora") { + return "zora-mainnet"; + } + if (chainName === "base") { + return "base-mainnet"; + } + + return chainName; +}; + +export async function getChainConfig(chainName: string) { + const subgraph = getSubgraph( + `zora-create-${subgraphChainName(chainName)}`, + "stable", + ); + const { publicClient, walletClient, chainId } = + await makeClientsForChain(chainName); + + return { + publicClient: publicClient as PublicClient, + walletClient: walletClient as WalletClient, + chainId, + subgraph, + }; +} diff --git a/packages/comments/slither.config.json b/packages/comments/slither.config.json new file mode 100644 index 000000000..1a6b92dec --- /dev/null +++ b/packages/comments/slither.config.json @@ -0,0 +1,7 @@ +{ + "filter_paths": "(node_modules/,test/)", + "solc_remaps": [ + "@zoralabs/=node_modules/@zoralabs/", + "@openzeppelin/=node_modules/@openzeppelin/" + ] +} \ No newline at end of file diff --git a/packages/comments/src/CommentsImpl.sol b/packages/comments/src/CommentsImpl.sol new file mode 100644 index 000000000..8b32b75ae --- /dev/null +++ b/packages/comments/src/CommentsImpl.sol @@ -0,0 +1,685 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {IERC1155} from "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; +import {IHasContractName} from "@zoralabs/shared-contracts/interfaces/IContractMetadata.sol"; +import {ContractVersionBase} from "./version/ContractVersionBase.sol"; +import {IZoraCreator1155} from "./interfaces/IZoraCreator1155.sol"; +import {IComments} from "./interfaces/IComments.sol"; +import {IProtocolRewards} from "@zoralabs/protocol-rewards/src/interfaces/IProtocolRewards.sol"; +import {UnorderedNoncesUpgradeable} from "@zoralabs/shared-contracts/utils/UnorderedNoncesUpgradeable.sol"; +import {EIP712UpgradeableWithChainId} from "./utils/EIP712UpgradeableWithChainId.sol"; +import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; +import {ERC1967Utils} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Utils.sol"; +import {IMultiOwnable} from "./interfaces/IMultiOwnable.sol"; +import {CommentsImplConstants} from "./CommentsImplConstants.sol"; + +/// @title CommentsImpl +/// @notice Contract for comments and sparking (liking with value) Zora 1155 posts. +/// @dev Implements comment creation, sparking, and backfilling functionality. Implementation contract +/// meant to be used with a UUPS upgradeable proxy contract. +/// @author oveddan / IsabellaSmallcombe +contract CommentsImpl is + IComments, + AccessControlUpgradeable, + UUPSUpgradeable, + ContractVersionBase, + EIP712UpgradeableWithChainId, + UnorderedNoncesUpgradeable, + CommentsImplConstants, + IHasContractName +{ + /// @notice keccak256(abi.encode(uint256(keccak256("comments.storage.CommentsStorage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant COMMENTS_STORAGE_LOCATION = 0x9e5d0d3a4c7e8d5b9e8f9d9d5b9e8f9d9d5b9e8f9d9d5b9e8f9d9d5b9e8f9d00; + /// @notice the spark value to comment + uint256 public immutable sparkValue; + /// @notice the address of the protocol rewards contract + IProtocolRewards public immutable protocolRewards; + /// @notice account that receives rewards Zora Rewards for from a portion of the sparks value + address immutable zoraRecipient; + + /// @custom:storage-location erc7201:comments.storage.CommentsStorage + struct CommentsStorage { + mapping(bytes32 => Comment) comments; + // gap that held old zora recipient. + address __gap; + // Global autoincrementing nonce + uint256 nonce; + } + + function _getCommentsStorage() private pure returns (CommentsStorage storage $) { + assembly { + $.slot := COMMENTS_STORAGE_LOCATION + } + } + + function comments(bytes32 commentId) internal view returns (Comment storage) { + return _getCommentsStorage().comments[commentId]; + } + + /// @notice Returns the total number of sparks a given comment has received + /// @param commentIdentifier The identifier of the comment + /// @return The total number of sparks a comment has received + function commentSparksQuantity(CommentIdentifier memory commentIdentifier) external view returns (uint256) { + return comments(hashCommentIdentifier(commentIdentifier)).totalSparks; + } + + /// @notice Returns the next nonce for comment creation + /// @return The next nonce + function nextNonce() external view returns (bytes32) { + return bytes32(_getCommentsStorage().nonce); + } + + /// @notice Returns the implementation address of the contract + /// @return The implementation address + function implementation() external view returns (address) { + return ERC1967Utils.getImplementation(); + } + + /// @notice Contract constructor + /// @param _sparkValue The value of a spark + /// @param _protocolRewards The address of the protocol rewards contract + /// @param _zoraRecipient The address of the zora recipient + constructor(uint256 _sparkValue, address _protocolRewards, address _zoraRecipient) { + if (_protocolRewards == address(0) || _zoraRecipient == address(0)) { + revert AddressZero(); + } + _disableInitializers(); + + sparkValue = _sparkValue; + protocolRewards = IProtocolRewards(_protocolRewards); + zoraRecipient = _zoraRecipient; + } + + /// @notice Initializes the contract with default admin, backfiller, and delegate commenters + /// @param defaultAdmin The address of the default admin + /// @param backfiller The address of the backfiller + /// @param delegateCommenters The addresses of the delegate commenters + function initialize(address defaultAdmin, address backfiller, address[] calldata delegateCommenters) public initializer { + if (defaultAdmin == address(0) || backfiller == address(0)) { + revert AddressZero(); + } + __AccessControl_init(); + __UUPSUpgradeable_init(); + __EIP712_init(DOMAIN_NAME, DOMAIN_VERSION); + + _grantRole(DEFAULT_ADMIN_ROLE, defaultAdmin); + _grantRole(BACKFILLER_ROLE, backfiller); + + for (uint256 i = 0; i < delegateCommenters.length; i++) { + _grantRole(DELEGATE_COMMENTER, delegateCommenters[i]); + } + } + + /// @notice Hashes a comment identifier to generate a unique ID + /// @param commentIdentifier The comment identifier to hash + /// @return The hashed comment identifier + function hashCommentIdentifier(CommentIdentifier memory commentIdentifier) public pure returns (bytes32) { + return keccak256(abi.encode(commentIdentifier)); + } + + /// @notice Hashes a comment identifier and checks if a comment exists with that id + /// @param commentIdentifier The comment identifier to check + /// @return commentId The hashed comment identifier + /// @return exists Whether the comment exists + function hashAndCheckCommentExists(CommentIdentifier memory commentIdentifier) public view returns (bytes32 commentId, bool exists) { + commentId = hashCommentIdentifier(commentIdentifier); + exists = comments(commentId).exists; + } + + /// @notice Validates that a comment exists and returns its ID + /// @param commentIdentifier The comment identifier to validate + /// @return commentId The hashed comment identifier + function hashAndValidateCommentExists(CommentIdentifier memory commentIdentifier) public view returns (bytes32 commentId) { + bool exists; + (commentId, exists) = hashAndCheckCommentExists(commentIdentifier); + if (!exists) { + revert CommentDoesntExist(); + } + } + + /// @notice Creates a new comment. Equivalant sparks value in eth must be sent with the transaction. Must be a holder or creator of the referenced 1155 token. + /// If not the owner, must send 1 spark. + /// @param contractAddress The address of the contract + /// @param tokenId The token ID + /// @param commenter The address of the commenter + /// @param text The text content of the comment + /// @param replyTo The identifier of the comment being replied to (if any) + /// @param commenterSmartWallet If the commenter has a smart wallet, the smart wallet, which can checked to be the owner or creator of the 1155 token + /// @param referrer The address of the referrer (if any) + /// @return commentIdentifier The identifier of the created comment, including the nonce + function comment( + address commenter, + address contractAddress, + uint256 tokenId, + string calldata text, + CommentIdentifier calldata replyTo, + address commenterSmartWallet, + address referrer + ) external payable returns (CommentIdentifier memory commentIdentifier) { + uint256 sparksQuantity = _getAndValidateSingleSparkQuantityFromValue(msg.value); + + commentIdentifier = _createCommentIdentifier(contractAddress, tokenId, commenter); + + _comment({ + commenter: msg.sender, + commentIdentifier: commentIdentifier, + text: text, + sparksQuantity: sparksQuantity, + replyTo: replyTo, + commenterSmartWallet: commenterSmartWallet, + referrer: referrer, + mustSendAtLeastOneSpark: true + }); + } + + // gets the sparks quantity from the value sent with the transaction, + // ensuring that at most 1 spark is sent. + function _getAndValidateSingleSparkQuantityFromValue(uint256 value) internal view returns (uint256) { + if (value == 0) { + return 0; + } + if (value != sparkValue) { + revert IncorrectETHAmountForSparks(value, sparkValue); + } + return 1; + } + + // Allows another contract to call this function to signify a caller commented, and is trusted + // to provide who the original commenter was. Allows no sparks to be sent. + /// @notice Allows another contract to delegate comment creation on behalf of a user + /// @param commenter The address of the commenter + /// @param contractAddress The address of the contract + /// @param tokenId The token ID + /// @param text The text content of the comment + /// @param replyTo The identifier of the comment being replied to (if any) + /// @param referrer The address of the referrer (if any) + /// @param commenterSmartWalletOwner If the commenter has a smart wallet, the smart wallet owner address + /// @return commentIdentifier The identifier of the created comment, including the nonce + function delegateComment( + address commenter, + address contractAddress, + uint256 tokenId, + string calldata text, + CommentIdentifier calldata replyTo, + address commenterSmartWalletOwner, + address referrer + ) external payable onlyRole(DELEGATE_COMMENTER) returns (CommentIdentifier memory commentIdentifier, bytes32 commentId) { + uint256 sparksQuantity = _getAndValidateSingleSparkQuantityFromValue(msg.value); + + commentIdentifier = _createCommentIdentifier(contractAddress, tokenId, commenter); + + commentId = _comment({ + commenter: commentIdentifier.commenter, + commentIdentifier: commentIdentifier, + text: text, + sparksQuantity: sparksQuantity, + replyTo: replyTo, + commenterSmartWallet: commenterSmartWalletOwner, + referrer: referrer, + mustSendAtLeastOneSpark: false + }); + } + + function _createCommentIdentifier(address contractAddress, uint256 tokenId, address commenter) private returns (CommentIdentifier memory) { + CommentsStorage storage $ = _getCommentsStorage(); + return CommentIdentifier({commenter: commenter, contractAddress: contractAddress, tokenId: tokenId, nonce: bytes32($.nonce++)}); + } + + function _comment( + address commenter, + CommentIdentifier memory commentIdentifier, + string memory text, + uint256 sparksQuantity, + CommentIdentifier memory replyTo, + address commenterSmartWallet, + address referrer, + bool mustSendAtLeastOneSpark + ) internal returns (bytes32) { + if (commentIdentifier.commenter != commenter) { + revert CommenterMismatch(commentIdentifier.commenter, commenter); + } + + (bytes32 commentId, bytes32 replyToId) = _validateComment( + commentIdentifier, + replyTo, + text, + sparksQuantity, + commenterSmartWallet, + mustSendAtLeastOneSpark + ); + + _saveCommentAndTransferSparks(commentId, commentIdentifier, text, sparksQuantity, replyToId, replyTo, block.timestamp, referrer); + + return commentId; + } + + function _validateIdentifiersMatch(CommentIdentifier memory commentIdentifier, CommentIdentifier memory replyTo) internal pure { + if (commentIdentifier.contractAddress != replyTo.contractAddress || commentIdentifier.tokenId != replyTo.tokenId) { + revert CommentAddressOrTokenIdsDoNotMatch(commentIdentifier.contractAddress, commentIdentifier.tokenId, replyTo.contractAddress, replyTo.tokenId); + } + } + + function _validateComment( + CommentIdentifier memory commentIdentifier, + CommentIdentifier memory replyTo, + string memory text, + uint256 sparksQuantity, + address commenterSmartWallet, + bool mustSendAtLeastOneSpark + ) internal view returns (bytes32 commentId, bytes32 replyToId) { + // verify that the commenter specified in the identifier is the one expected + commentId = hashCommentIdentifier(commentIdentifier); + + if (replyTo.commenter != address(0)) { + replyToId = hashAndValidateCommentExists(replyTo); + _validateIdentifiersMatch(commentIdentifier, replyTo); + } + + if (bytes(text).length == 0) { + revert EmptyComment(); + } + + _validateCommenterAndSparksQuantity(commentIdentifier, sparksQuantity, mustSendAtLeastOneSpark, commenterSmartWallet); + } + + function _validateCommenterAndSparksQuantity( + CommentIdentifier memory commentIdentifier, + uint256 sparksQuantity, + bool mustSendAtLeastOneSpark, + address commenterSmartWallet + ) internal view { + if (commenterSmartWallet != address(0)) { + if (commenterSmartWallet.code.length == 0) { + revert NotSmartWallet(); + } + // check if the commenter is a smart wallet owner + if (!IMultiOwnable(commenterSmartWallet).isOwnerAddress(commentIdentifier.commenter)) { + revert NotSmartWalletOwner(); + } + } + + // check that the commenter or smart wallet is a token admin - if they are, then they can comment for free + if ( + _accountOrSmartWalletIsTokenAdmin(commentIdentifier.contractAddress, commentIdentifier.tokenId, commentIdentifier.commenter, commenterSmartWallet) + ) { + return; + } + // if they aren't, commenter or smart wallet must be a token holder, and have included at least 1 spark + if ( + !_accountOrSmartWalletIsTokenHolder(commentIdentifier.contractAddress, commentIdentifier.tokenId, commentIdentifier.commenter, commenterSmartWallet) + ) { + revert NotTokenHolderOrAdmin(); + } + + if (mustSendAtLeastOneSpark && sparksQuantity == 0) { + revert MustSendAtLeastOneSpark(); + } + } + + function _getRewardDeposits( + address sparksRecipient, + address referrer, + uint256 sparksValue + ) internal view returns (address[] memory, uint256[] memory, bytes4[] memory) { + uint256 recipientCount = referrer != address(0) ? 3 : 2; + address[] memory recipients = new address[](recipientCount); + uint256[] memory amounts = new uint256[](recipientCount); + bytes4[] memory reasons = new bytes4[](recipientCount); + + if (referrer != address(0)) { + uint256 zoraReward = (ZORA_REWARD_PCT * sparksValue) / BPS_TO_PERCENT_2_DECIMAL_PERCISION; + recipients[0] = zoraRecipient; + amounts[0] = zoraReward; + reasons[0] = ZORA_REWARD_REASON; + + uint256 referrerReward = (REFERRER_REWARD_PCT * sparksValue) / BPS_TO_PERCENT_2_DECIMAL_PERCISION; + recipients[1] = referrer; + amounts[1] = referrerReward; + reasons[1] = REFERRER_REWARD_REASON; + + uint256 sparksRecipientReward = sparksValue - zoraReward - referrerReward; + recipients[2] = sparksRecipient; + amounts[2] = sparksRecipientReward; + reasons[2] = SPARKS_RECIPIENT_REWARD_REASON; + } else { + uint256 zoraRewardNoReferrer = (ZORA_REWARD_NO_REFERRER_PCT * sparksValue) / BPS_TO_PERCENT_2_DECIMAL_PERCISION; + recipients[0] = zoraRecipient; + amounts[0] = zoraRewardNoReferrer; + reasons[0] = ZORA_REWARD_REASON; + + uint256 sparkRecipientReward = sparksValue - zoraRewardNoReferrer; + recipients[1] = sparksRecipient; + amounts[1] = sparkRecipientReward; + reasons[1] = SPARKS_RECIPIENT_REWARD_REASON; + } + + return (recipients, amounts, reasons); + } + + function _transferSparksValueToRecipient(address sparksRecipient, address referrer, uint256 sparksValue, string memory depositBatchComment) internal { + (address[] memory recipients, uint256[] memory amounts, bytes4[] memory reasons) = _getRewardDeposits(sparksRecipient, referrer, sparksValue); + protocolRewards.depositBatch{value: sparksValue}(recipients, amounts, reasons, depositBatchComment); + } + + function _accountOrSmartWalletIsTokenAdmin(address contractAddress, uint256 tokenId, address user, address smartWallet) internal view returns (bool) { + if (_isTokenAdmin(contractAddress, tokenId, user)) { + return true; + } + if (smartWallet != address(0)) { + return _isTokenAdmin(contractAddress, tokenId, smartWallet); + } + return false; + } + + function _accountOrSmartWalletIsTokenHolder(address contractAddress, uint256 tokenId, address user, address smartWallet) internal view returns (bool) { + if (_isTokenHolder(contractAddress, tokenId, user)) { + return true; + } + if (smartWallet != address(0)) { + return _isTokenHolder(contractAddress, tokenId, smartWallet); + } + return false; + } + + function _isTokenAdmin(address contractAddress, uint256 tokenId, address user) internal view returns (bool) { + return IZoraCreator1155(contractAddress).isAdminOrRole(user, tokenId, PERMISSION_BIT_ADMIN); + } + + function _isTokenHolder(address contractAddress, uint256 tokenId, address user) internal view returns (bool) { + return IERC1155(contractAddress).balanceOf(user, tokenId) > 0; + } + + function _getCommentSparksRecipient(CommentIdentifier memory commentIdentifier, CommentIdentifier memory replyTo) internal view returns (address) { + // if there is no reply to, then creator reward recipient of the 1155 token gets the sparks + // otherwise, the replay to commenter gets the sparks + if (replyTo.commenter == address(0)) { + return _getCreatorRewardRecipient(commentIdentifier); + } + + return replyTo.commenter; + } + + // executes the comment. assumes sparks have already been transferred to recipient, and data has been validated + // assume that the commentId and replyToId are valid + function _saveCommentAndTransferSparks( + bytes32 commentId, + CommentIdentifier memory commentIdentifier, + string memory text, + uint256 sparksQuantity, + bytes32 replyToId, + CommentIdentifier memory replyToIdentifier, + uint256 timestamp, + address referrer + ) internal { + _saveComment(commentId, commentIdentifier, text, sparksQuantity, replyToId, replyToIdentifier, timestamp, referrer); + string memory depositBatchComment = "Comment"; + + // update reason if replying to a comment + if (replyToId != 0) { + depositBatchComment = "Comment Reply"; + } + + if (sparksQuantity > 0) { + address sparksRecipient = _getCommentSparksRecipient(commentIdentifier, replyToIdentifier); + _transferSparksValueToRecipient(sparksRecipient, referrer, sparksQuantity * sparkValue, depositBatchComment); + } + } + + function _saveComment( + bytes32 commentId, + CommentIdentifier memory commentIdentifier, + string memory text, + uint256 sparksQuantity, + bytes32 replyToId, + CommentIdentifier memory replyToIdentifier, + uint256 timestamp, + address referrer + ) internal { + if (comments(commentId).exists) { + revert DuplicateComment(commentId); + } + comments(commentId).exists = true; + + emit Commented(commentId, commentIdentifier, replyToId, replyToIdentifier, sparksQuantity, text, timestamp, referrer); + } + + /// @notice Sparks a comment. Equivalant sparks value in eth to sparksQuantity must be sent with the transaction. Sparking a comment is + /// similar to liking it, except it is liked with the value of sparks attached. The spark value gets sent to the commenter, with a fee taken out. + /// @param commentIdentifier The identifier of the comment to spark + /// @param sparksQuantity The quantity of sparks to send + /// @param referrer The referrer of the comment + function sparkComment(CommentIdentifier calldata commentIdentifier, uint256 sparksQuantity, address referrer) public payable { + if (sparksQuantity == 0) { + revert MustSendAtLeastOneSpark(); + } + _validateSparksQuantityMatchesValue(sparksQuantity, msg.value); + _sparkComment(commentIdentifier, msg.sender, sparksQuantity, referrer); + } + + function _validateSparksQuantityMatchesValue(uint256 sparksQuantity, uint256 value) internal view { + if (value != sparksQuantity * sparkValue) { + revert IncorrectETHAmountForSparks(value, sparksQuantity * sparkValue); + } + } + + function _sparkComment(CommentIdentifier memory commentIdentifier, address sparker, uint256 sparksQuantity, address referrer) internal { + if (sparker == commentIdentifier.commenter) { + revert CannotSparkOwnComment(); + } + bytes32 commentId = hashCommentIdentifier(commentIdentifier); + if (!comments(commentId).exists) { + revert CommentDoesntExist(); + } + + comments(commentId).totalSparks += uint256(sparksQuantity); + + _transferSparksValueToRecipient(commentIdentifier.commenter, referrer, sparksQuantity * sparkValue, "Sparked Comment"); + + emit SparkedComment(commentId, commentIdentifier, sparksQuantity, sparker, block.timestamp, referrer); + } + + function _hashCommentIdentifier(CommentIdentifier memory commentIdentifier) internal pure returns (bytes32) { + return + keccak256( + abi.encode( + COMMENT_IDENTIFIER_DOMAIN, + commentIdentifier.contractAddress, + commentIdentifier.tokenId, + commentIdentifier.commenter, + commentIdentifier.nonce + ) + ); + } + + /// @notice Hashes a permit comment struct for signing + /// @param permit The permit comment struct + /// @return The hash to sign + function hashPermitComment(PermitComment calldata permit) public view returns (bytes32) { + bytes32 structHash = keccak256( + abi.encode( + PERMIT_COMMENT_DOMAIN, + permit.contractAddress, + permit.tokenId, + permit.commenter, + _hashCommentIdentifier(permit.replyTo), + keccak256(bytes(permit.text)), + permit.deadline, + permit.nonce, + permit.commenterSmartWallet, + permit.referrer, + permit.sourceChainId, + permit.destinationChainId + ) + ); + + return _hashTypedDataV4(structHash, permit.sourceChainId); + } + + function _validatePermit(bytes32 digest, bytes32 nonce, bytes calldata signature, address signer, uint256 deadline) internal { + if (block.timestamp > deadline) { + revert ERC2612ExpiredSignature(deadline); + } + + _useCheckedNonce(signer, nonce); + _validateSignerIsCommenter(digest, signature, signer); + } + + /// @notice Creates a comment on behalf of another account using a signed message. Supports cross-chain permits + /// by specifying the source and destination chain ids. The signature must be signed by the commenter on the source chain. + /// @param permit The permit that was signed off-chain on the source chain + /// @param signature The signature of the permit comment + function permitComment(PermitComment calldata permit, bytes calldata signature) public payable { + if (permit.destinationChainId != uint32(block.chainid)) { + revert IncorrectDestinationChain(permit.destinationChainId); + } + + bytes32 digest = hashPermitComment(permit); + _validatePermit(digest, permit.nonce, signature, permit.commenter, permit.deadline); + + CommentIdentifier memory commentIdentifier = _createCommentIdentifier(permit.contractAddress, permit.tokenId, permit.commenter); + + uint256 sparksQuantity = _getAndValidateSingleSparkQuantityFromValue(msg.value); + + (bytes32 commentId, bytes32 replyToId) = _validateComment( + commentIdentifier, + permit.replyTo, + permit.text, + sparksQuantity, + permit.commenterSmartWallet, + true + ); + + _saveCommentAndTransferSparks(commentId, commentIdentifier, permit.text, sparksQuantity, replyToId, permit.replyTo, block.timestamp, permit.referrer); + } + + /// @notice Hashes a permit spark comment struct for signing + /// @param permit The permit spark comment struct + function hashPermitSparkComment(PermitSparkComment calldata permit) public view returns (bytes32) { + bytes32 structHash = keccak256( + abi.encode( + PERMIT_SPARK_COMMENT_DOMAIN, + _hashCommentIdentifier(permit.comment), + permit.sparker, + permit.sparksQuantity, + permit.deadline, + permit.nonce, + permit.referrer, + permit.sourceChainId, + permit.destinationChainId + ) + ); + return _hashTypedDataV4(structHash, permit.sourceChainId); + } + + /// @notice Sparks a comment on behalf of another account using a signed message. Supports cross-chain permits + /// by specifying the source and destination chain ids. The signature must be signed by the sparker on the source chain. + /// @param permit The permit spark comment struct + /// @param signature The signature of the permit. Must be signed by the sparker. + function permitSparkComment(PermitSparkComment calldata permit, bytes calldata signature) public payable { + if (permit.destinationChainId != uint32(block.chainid)) { + revert IncorrectDestinationChain(permit.destinationChainId); + } + + bytes32 digest = hashPermitSparkComment(permit); + _validatePermit(digest, permit.nonce, signature, permit.sparker, permit.deadline); + + if (permit.sparksQuantity == 0) { + revert MustSendAtLeastOneSpark(); + } + + _validateSparksQuantityMatchesValue(permit.sparksQuantity, msg.value); + + _sparkComment(permit.comment, permit.sparker, permit.sparksQuantity, permit.referrer); + } + + function _validateSignerIsCommenter(bytes32 digest, bytes calldata signature, address signer) internal view { + if (!SignatureChecker.isValidSignatureNow(signer, digest, signature)) { + revert InvalidSignature(); + } + } + + /// @notice Backfills comments created by other contracts. Only callable by an account with the backfiller role. + /// @param commentIdentifiers Array of comment identifiers + /// @param texts Array of comment texts + /// @param timestamps Array of comment timestamps + /// @param originalTransactionHashes Array of original transaction hashes + function backfillBatchAddComment( + CommentIdentifier[] calldata commentIdentifiers, + string[] calldata texts, + uint256[] calldata timestamps, + bytes32[] calldata originalTransactionHashes + ) public onlyRole(BACKFILLER_ROLE) { + if (commentIdentifiers.length != texts.length || texts.length != timestamps.length || timestamps.length != originalTransactionHashes.length) { + revert ArrayLengthMismatch(); + } + + for (uint256 i = 0; i < commentIdentifiers.length; i++) { + bytes32 commentId = hashCommentIdentifier(commentIdentifiers[i]); + + if (comments(commentId).exists) { + revert DuplicateComment(commentId); + } + comments(commentId).exists = true; + + // create blank replyTo - assume that these were created without replyTo + emit BackfilledComment(commentId, commentIdentifiers[i], texts[i], timestamps[i], originalTransactionHashes[i]); + } + } + + function _getFundsRecipient(address contractAddress) internal view returns (address) { + try IZoraCreator1155(contractAddress).config() returns (address, uint96, address payable fundsRecipient, uint96, address, uint96) { + if (fundsRecipient != address(0)) { + return fundsRecipient; + } + } catch {} + + try IZoraCreator1155(contractAddress).owner() returns (address owner) { + if (owner != address(0)) { + return owner; + } + } catch {} + + return address(0); + } + + function _tryGetCreatorRewardRecipient(CommentIdentifier memory commentIdentifier) internal view returns (address) { + try IZoraCreator1155(commentIdentifier.contractAddress).getCreatorRewardRecipient(commentIdentifier.tokenId) returns (address creatorRecipient) { + return creatorRecipient; + } catch { + return address(0); + } + } + + function _getCreatorRewardRecipient(CommentIdentifier memory commentIdentifier) internal view returns (address) { + address creatorRecipient = _tryGetCreatorRewardRecipient(commentIdentifier); + if (creatorRecipient != address(0)) { + return creatorRecipient; + } + + address fundsRecipient = _getFundsRecipient(commentIdentifier.contractAddress); + if (fundsRecipient != address(0)) { + return fundsRecipient; + } + revert NoFundsRecipient(); + } + + /// @notice Returns the name of the contract + /// @return The name of the contract + function contractName() public pure returns (string memory) { + return "Zora Comments"; + } + + function _authorizeUpgrade(address newImplementation) internal override onlyRole(DEFAULT_ADMIN_ROLE) { + // check that new implementation's contract name matches the current contract name + if (!_equals(IHasContractName(newImplementation).contractName(), this.contractName())) { + revert UpgradeToMismatchedContractName(this.contractName(), IHasContractName(newImplementation).contractName()); + } + } + + function _equals(string memory a, string memory b) internal pure returns (bool) { + return (keccak256(bytes(a)) == keccak256(bytes(b))); + } +} diff --git a/packages/comments/src/CommentsImplConstants.sol b/packages/comments/src/CommentsImplConstants.sol new file mode 100644 index 000000000..5d54dc5dd --- /dev/null +++ b/packages/comments/src/CommentsImplConstants.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +/// @title CommentsImplConstants +/// @notice Constants for the CommentsImpl contract +/// @author oveddan / IsabellaSmallcombe +contract CommentsImplConstants { + /// @notice this is the zora creator multisig that can upgrade the contract + bytes32 public constant BACKFILLER_ROLE = keccak256("BACKFILLER_ROLE"); + /// @notice allows to delegate comment + bytes32 public constant DELEGATE_COMMENTER = keccak256("DELEGATE_COMMENTER"); + /// @notice permission bit for admin + uint256 public constant PERMISSION_BIT_ADMIN = 2 ** 1; + /// @notice Zora reward percentage + uint256 public constant ZORA_REWARD_PCT = 10; + /// @notice referrer reward percentage + uint256 public constant REFERRER_REWARD_PCT = 20; + /// @notice Zora reward percentage when there is no referrer + uint256 public constant ZORA_REWARD_NO_REFERRER_PCT = 30; + /// @notice BPS to percent conversion + uint256 internal constant BPS_TO_PERCENT_2_DECIMAL_PERCISION = 100; + /// @notice domain name for comments + string public constant DOMAIN_NAME = "Comments"; + /// @notice domain version for comments + string public constant DOMAIN_VERSION = "1"; + /// @notice Zora reward reason + bytes4 constant ZORA_REWARD_REASON = bytes4(keccak256("zoraRewardForCommentDeposited()")); + /// @notice referrer reward reason + bytes4 constant REFERRER_REWARD_REASON = bytes4(keccak256("referrerRewardForCommentDeposited()")); + /// @notice sparks recipient reward reason + bytes4 constant SPARKS_RECIPIENT_REWARD_REASON = bytes4(keccak256("sparksRecipientRewardForCommentDeposited()")); + /// @notice permint comment domain + bytes32 constant PERMIT_COMMENT_DOMAIN = + keccak256( + "PermitComment(address contractAddress,uint256 tokenId,address commenter,CommentIdentifier replyTo,string text,uint256 deadline,bytes32 nonce,address commenterSmartWallet,address referrer,uint32 sourceChainId,uint32 destinationChainId)CommentIdentifier(address contractAddress,uint256 tokenId,address commenter,bytes32 nonce)" + ); + /// @notice comment identifier domain + bytes32 constant COMMENT_IDENTIFIER_DOMAIN = keccak256("CommentIdentifier(address contractAddress,uint256 tokenId,address commenter,bytes32 nonce)"); + /// @notice permit spark comment domain + bytes32 constant PERMIT_SPARK_COMMENT_DOMAIN = + keccak256( + "PermitSparkComment(CommentIdentifier comment,address sparker,uint256 sparksQuantity,uint256 deadline,bytes32 nonce,address referrer,uint32 sourceChainId,uint32 destinationChainId)CommentIdentifier(address contractAddress,uint256 tokenId,address commenter,bytes32 nonce)" + ); +} diff --git a/packages/comments/src/interfaces/ICallerAndCommenter.sol b/packages/comments/src/interfaces/ICallerAndCommenter.sol new file mode 100644 index 000000000..463e044f2 --- /dev/null +++ b/packages/comments/src/interfaces/ICallerAndCommenter.sol @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IComments} from "./IComments.sol"; + +interface ICallerAndCommenter { + /// @notice Occurs when a signature is invalid + error InvalidSignature(); + + /// @notice Occurs when the deadline has expired + error ERC2612ExpiredSignature(uint256 deadline); + /// @notice Occurs when the destination chain ID doesn't match the current chain ID in a permit + error IncorrectDestinationChain(uint256 wrongDestinationChainId); + + /// @notice Occurs when attempting to upgrade to a contract with a name that doesn't match the current contract's name + /// @param currentName The name of the current contract + /// @param newName The name of the contract being upgraded to + error UpgradeToMismatchedContractName(string currentName, string newName); + + /// @notice Occurs when the commenter address doesn't match the expected address + /// @param expected The address that was expected to be the commenter + /// @param actual The actual address that attempted to comment + error CommenterMismatch(address expected, address actual); + + /// @notice Error thrown when attempting to buy tokens for a collection and tokenId that doesn't have an active sale + /// @param collection The address of the collection + /// @param tokenId The ID of the token + error SaleNotSet(address collection, uint256 tokenId); + + /// @notice Error thrown when the wrong amount of ETH is sent + /// @param expected The expected amount of ETH + /// @param actual The actual amount of ETH sent + error WrongValueSent(uint256 expected, uint256 actual); + + /// @notice Struct representing a permit for timed sale minting and commenting + struct PermitTimedSaleMintAndComment { + /// @dev The account that is creating the comment and minting tokens. + /// Must match the account that is signing the permit + address commenter; + /// @dev The number of tokens to mint + uint256 quantity; + /// @dev The address of the collection contract to mint from + address collection; + /// @dev The token ID to mint + uint256 tokenId; + /// @dev The address to receive mint referral rewards, if any + address mintReferral; + /// @dev The text of the comment + string comment; + /// @dev Permit deadline - execution of permit must be before this timestamp + uint256 deadline; + /// @dev Nonce to prevent replay attacks + bytes32 nonce; + /// @dev Chain ID where the permit was signed + uint32 sourceChainId; + /// @dev Chain ID where the permit should be executed + uint32 destinationChainId; + } + + /// @notice Struct representing a permit for buying on secondary market and commenting + struct PermitBuyOnSecondaryAndComment { + /// @dev The account that is creating the comment and buying tokens. + /// Must match the account that is signing the permit + address commenter; + /// @dev The number of tokens to buy + uint256 quantity; + /// @dev The address of the collection contract to buy from + address collection; + /// @dev The token ID to buy + uint256 tokenId; + /// @dev The maximum amount of ETH to spend on the purchase + uint256 maxEthToSpend; + /// @dev The sqrt price limit for the swap + uint160 sqrtPriceLimitX96; + /// @dev The text of the comment + string comment; + /// @dev Permit deadline - execution of permit must be before this timestamp + uint256 deadline; + /// @dev Nonce to prevent replay attacks + bytes32 nonce; + /// @dev Chain ID where the permit was signed + uint32 sourceChainId; + /// @dev Chain ID where the permit should be executed + uint32 destinationChainId; + } + + enum SwapDirection { + BUY, + SELL + } + + /// @notice Emitted when tokens are bought or sold on the secondary market and a comment is added + /// @param commentId The unique identifier of the comment + /// @param commentIdentifier The struct containing details about the comment + /// @param quantity The number of tokens bought + /// @param comment The content of the comment + /// @param swapDirection The direction of the swap + event SwappedOnSecondaryAndCommented( + bytes32 indexed commentId, + IComments.CommentIdentifier commentIdentifier, + uint256 indexed quantity, + string comment, + SwapDirection indexed swapDirection + ); + + /// @notice Emitted when tokens are minted and a comment is added + /// @param commentId The unique identifier of the comment + /// @param commentIdentifier The struct containing details about the comment + /// @param quantity The number of tokens minted + /// @param text The content of the comment + event MintedAndCommented(bytes32 indexed commentId, IComments.CommentIdentifier commentIdentifier, uint256 quantity, string text); + + /// @notice Initializes the upgradeable contract + /// @param owner of the contract that can perform upgrades + function initialize(address owner) external; + + /// @notice Mints tokens and adds a comment, without needing to pay a spark for the comment. + /// @dev The payable amount should be the total mint fee. No spark value should be sent. + /// @param commenter The address of the commenter + /// @param quantity The number of tokens to mint + /// @param collection The address of the 1155 collection to mint from + /// @param tokenId The 1155 token Id to mint + /// @param mintReferral The address to receive mint referral rewards, if any + /// @param comment The comment to be added. If empty, no comment will be added. + /// @return The identifier of the newly created comment + function timedSaleMintAndComment( + address commenter, + uint256 quantity, + address collection, + uint256 tokenId, + address mintReferral, + string calldata comment + ) external payable returns (IComments.CommentIdentifier memory); + + /// @notice Mints tokens and adds a comment, without needing to pay a spark for the comment. Attributes the + /// comment to the signer of the message. Meant to be used for cross-chain commenting. where a permit + /// is signed in a chain and then executed in another chain. + /// @dev The signer must match the commenter field in the permit. + /// @param permit The PermitTimedSaleMintAndComment struct containing the permit data + /// @param signature The signature of the permit + /// @return The identifier of the newly created comment + function permitTimedSaleMintAndComment( + PermitTimedSaleMintAndComment calldata permit, + bytes calldata signature + ) external payable returns (IComments.CommentIdentifier memory); + + /// @notice Hashes the permit data for a timed sale mint and comment operation + /// @param permit The PermitTimedSaleMintAndComment struct containing the permit data + /// @return bytes32 The hash of the permit data for signing + function hashPermitTimedSaleMintAndComment(PermitTimedSaleMintAndComment memory permit) external view returns (bytes32); + + /// @notice Buys Zora 1155 tokens on secondary market and adds a comment, without needing to pay a spark for the comment. + /// @param commenter The address of the commenter. Must match the msg.sender. Commenter will be the recipient of the bought tokens. + /// @param quantity The number of tokens to buy + /// @param collection The address of the 1155 collection + /// @param tokenId The 1155 token Id to buy + /// @param excessRefundRecipient The address to receive any excess ETH refund + /// @param maxEthToSpend The maximum amount of ETH to spend on the purchase + /// @param sqrtPriceLimitX96 The sqrt price limit for the swap + /// @param comment The comment to be added + /// @return The identifier of the newly created comment + /// @dev This function can only be called by the commenter themselves + function buyOnSecondaryAndComment( + address commenter, + uint256 quantity, + address collection, + uint256 tokenId, + address payable excessRefundRecipient, + uint256 maxEthToSpend, + uint160 sqrtPriceLimitX96, + string calldata comment + ) external payable returns (IComments.CommentIdentifier memory); + + /// @notice Buys tokens on secondary market and adds a comment using a permit + /// @dev The signer must match the commenter field in the permit. + /// @param permit The PermitBuyOnSecondaryAndComment struct containing the permit data + /// @param signature The signature of the permit + /// @return The identifier of the newly created comment + function permitBuyOnSecondaryAndComment( + PermitBuyOnSecondaryAndComment calldata permit, + bytes calldata signature + ) external payable returns (IComments.CommentIdentifier memory); + + /// @notice Hashes the permit data for a buy on secondary and comment operation + /// @param permit The PermitBuyOnSecondaryAndComment struct containing the permit data + /// @return bytes32 The hash of the permit data for signing + function hashPermitBuyOnSecondaryAndComment(PermitBuyOnSecondaryAndComment memory permit) external view returns (bytes32); + + /// @notice Sells Zora 1155 tokens on secondary market and adds a comment. A spark needs to be paid for the comment, if a comment + /// is to be added. + /// @param commenter The address of the commenter. Must match the msg.sender. Commenter will be the seller of the tokens. + /// @param quantity The number of tokens to sell + /// @param collection The address of the 1155 collection + /// @param tokenId The 1155 token Id to sell + /// @param recipient The address to receive the ETH proceeds + /// @param minEthToAcquire The minimum amount of ETH to receive from the sale + /// @param sqrtPriceLimitX96 The sqrt price limit for the swap + /// @param comment The comment to be added + /// @return The identifier of the newly created comment + /// @dev This function can only be called by the commenter themselves + function sellOnSecondaryAndComment( + address commenter, + uint256 quantity, + address collection, + uint256 tokenId, + address payable recipient, + uint256 minEthToAcquire, + uint160 sqrtPriceLimitX96, + string calldata comment + ) external payable returns (IComments.CommentIdentifier memory); + + /// @notice Returns the address of the comments contract + /// @return address The address of the comments contract + function comments() external view returns (IComments); +} diff --git a/packages/comments/src/interfaces/IComments.sol b/packages/comments/src/interfaces/IComments.sol new file mode 100644 index 000000000..5d6ad5407 --- /dev/null +++ b/packages/comments/src/interfaces/IComments.sol @@ -0,0 +1,303 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +/// @title IComments +/// @notice Interface for the Comments contract, which allows for comments and sparking (liking with value) on Zora 1155 posts +/// @author oveddan / IsabellaSmallcombe +interface IComments { + /// @notice Struct representing a unique identifier for a comment + struct CommentIdentifier { + address commenter; + address contractAddress; + uint256 tokenId; + bytes32 nonce; + } + + /// @notice Struct representing a comment + struct Comment { + // has this comment been created + bool exists; + // total sparks for this comment + uint256 totalSparks; + } + + /// @notice Struct representing a permit for creating a comment + struct PermitComment { + // The account that is creating the comment. + // Must match the account that is signing the permit + address commenter; + // If the commenter, has a smart wallet, the smart wallet address. + // If not, zero address. If set, this address can be checked + // to see if the token is owned by the smart wallet. + address commenterSmartWallet; + // The contract address that is being commented on + address contractAddress; + // The token ID that is being commented on + uint256 tokenId; + // The comment identifier of the comment being replied to + CommentIdentifier replyTo; + // The text of the comment + string text; + // Referrer address - will get referral reward from the spark + address referrer; + // Permit deadline - execution of permit must be before this timestamp + uint256 deadline; + // Nonce to prevent replay attacks + bytes32 nonce; + // Chain the permit was signed on + uint32 sourceChainId; + // Chain to execute the permit on + uint32 destinationChainId; + } + + /// @notice Struct representing a permit for sparking a comment + struct PermitSparkComment { + // Comment that is being sparked + CommentIdentifier comment; + // Address of the user that is sparking the comment. + // Must match the address that is signing the permit + address sparker; + // Number of sparks to spark + uint256 sparksQuantity; + // Permit deadline - execution of permit must be before this timestamp + uint256 deadline; + // Nonce to prevent replay attacks + bytes32 nonce; + // Referrer address - will get referral reward from the spark + address referrer; + // Chain the permit was signed on + uint32 sourceChainId; + // Chain to execute the permit on + uint32 destinationChainId; + } + + /// @notice Event emitted when a comment is created + /// @param commentId Unique ID for the comment, generated from a hash of the commentIdentifier + /// @param commentIdentifier Identifier for the comment, containing details about the comment + /// @param replyToId Unique ID of the comment being replied to (if any) + /// @param replyTo Identifier of the comment being replied to (if any) + /// @param sparksQuantity Number of sparks associated with this comment + /// @param text The actual text content of the comment + /// @param timestamp Timestamp when the comment was created + /// @param referrer Address of the referrer who referred the commenter, if any + event Commented( + bytes32 indexed commentId, // Unique ID for the comment, generated from a hash of the commentIdentifier + CommentIdentifier commentIdentifier, // Identifier for the comment, containing details about the comment + bytes32 replyToId, // Unique ID of the comment being replied to (if any) + CommentIdentifier replyTo, // Identifier of the comment being replied to (if any) + uint256 sparksQuantity, // Number of sparks associated with this comment + string text, // The actual text content of the comment + uint256 timestamp, // Timestamp when the comment was created + address referrer // Address of the referrer who referred the commenter, if any + ); + + /// @notice Event emitted when a comment is backfilled + /// @param commentId Unique identifier for the backfilled comment + /// @param commentIdentifier Identifier for the comment + /// @param text The actual text content of the backfilled comment + /// @param timestamp Timestamp when the original comment was created + /// @param originalTransactionId Transaction ID of the original comment (before backfilling) + event BackfilledComment( + bytes32 indexed commentId, // Unique identifier for the backfilled comment + CommentIdentifier commentIdentifier, // Identifier for the comment + string text, // The actual text content of the backfilled comment + uint256 timestamp, // Timestamp when the original comment was created + bytes32 originalTransactionId // Transaction ID of the original comment (before backfilling) + ); + + /// @notice Event emitted when a comment is Sparked + /// @param commentId Unique identifier of the comment being sparked + /// @param commentIdentifier Struct containing details about the comment and commenter + /// @param sparksQuantity Number of sparks added to the comment + /// @param sparker Address of the user who sparked the comment + /// @param timestamp Timestamp when the spark action occurred + /// @param referrer Address of the referrer who referred the sparker, if any + event SparkedComment( + bytes32 indexed commentId, // Unique identifier of the comment being sparked + CommentIdentifier commentIdentifier, // Struct containing details about the comment and commenter + uint256 sparksQuantity, // Number of sparks added to the comment + address sparker, // Address of the user who sparked the comment + uint256 timestamp, // Timestamp when the spark action occurred + address referrer // Address of the referrer who referred the sparker, if any + ); + + /// @notice Occurs when attempting to add a comment that already exists + /// @param commentId The unique identifier of the duplicate comment + error DuplicateComment(bytes32 commentId); + + /// @notice Occurs when the amount of ETH sent with the transaction doesn't match the corresponding sparks quantity + error IncorrectETHAmountForSparks(uint256 actual, uint256 expected); + + /// @notice Occurs when the commenter address doesn't match the expected address + /// @param expected The address that was expected to be the commenter + /// @param actual The actual address that attempted to comment + error CommenterMismatch(address expected, address actual); + + /// @notice Occurs when a non-token holder or non-admin attempts an action restricted to token holders or admins + error NotTokenHolderOrAdmin(); + + /// @notice Occurs when attempting to spark a comment without sending at least one spark + error MustSendAtLeastOneSpark(); + + /// @notice Occurs when attempting to submit an empty comment + error EmptyComment(); + + /// @notice Occurs when trying to interact with a comment that doesn't exist + error CommentDoesntExist(); + + /// @notice Occurs when a transfer of funds fails + error TransferFailed(); + + /// @notice Occurs when a user attempts to spark their own comment + error CannotSparkOwnComment(); + + /// @notice Occurs when a function restricted to the Sparks contract is called by another address + error OnlySparksContract(); + + /// @notice Occurs when attempting to upgrade to a contract with a name that doesn't match the current contract's name + /// @param currentName The name of the current contract + /// @param newName The name of the contract being upgraded to + error UpgradeToMismatchedContractName(string currentName, string newName); + + /// @notice Occurs when the lengths of arrays passed to a function do not match + error ArrayLengthMismatch(); + + /// @notice Occurs when the address or token IDs in a comment identifier do not match the expected values + /// @param commentAddress The address in the comment identifier + /// @param commentTokenId The token ID in the comment identifier + /// @param replyAddress The address in the reply identifier + /// @param replyTokenId The token ID in the reply identifier + error CommentAddressOrTokenIdsDoNotMatch(address commentAddress, uint256 commentTokenId, address replyAddress, uint256 replyTokenId); + + /// @notice Occurs when the signature is invalid + error InvalidSignature(); + + /// @notice Occurs when the destination chain ID doesn't match the current chain ID in a permit + error IncorrectDestinationChain(uint256 wrongDestinationChainId); + + /// @notice Occurs when the commenter is not a smart wallet owner + error NotSmartWalletOwner(); + + /// @notice Occurs when the address is not a smart wallet + error NotSmartWallet(); + + /// @notice Occurs when the deadline has expired + error ERC2612ExpiredSignature(uint256 deadline); + + /// @notice Occurs when the funds recipient does not exist + error NoFundsRecipient(); + + /// @notice Address cannot be zero + error AddressZero(); + + /// @notice Creates a new comment + /// @param commenter The address of the commenter + /// @param contractAddress The address of the contract + /// @param tokenId The token ID + /// @param text The text content of the comment + /// @param replyTo The identifier of the comment being replied to (if any) + /// @param commenterSmartWalletOwner If the commenter has a smart wallet, the smart wallet owner address + /// @param referrer The address of the referrer (if any) + /// @return commentIdentifier The identifier of the created comment, including the nonce + function comment( + address commenter, + address contractAddress, + uint256 tokenId, + string calldata text, + CommentIdentifier calldata replyTo, + address commenterSmartWalletOwner, + address referrer + ) external payable returns (CommentIdentifier memory); + + /// @notice Allows another contract to delegate comment creation on behalf of a user + /// @param commenter The address of the commenter + /// @param contractAddress The address of the contract + /// @param tokenId The token ID + /// @param text The text content of the comment + /// @param replyTo The identifier of the comment being replied to (if any) + /// @param commenterSmartWalletOwner If the commenter has a smart wallet, the smart wallet owner address + /// @param referrer The address of the referrer (if any) + /// @return commentIdentifier The identifier of the created comment, including the nonce + function delegateComment( + address commenter, + address contractAddress, + uint256 tokenId, + string calldata text, + CommentIdentifier calldata replyTo, + address commenterSmartWalletOwner, + address referrer + ) external payable returns (CommentIdentifier memory, bytes32 commentId); + + function initialize(address commentsAdmin, address backfiller, address[] calldata delegateCommenters) external; + + /// @notice Sparks a comment + /// @param commentIdentifier The identifier of the comment to spark + /// @param sparksQuantity The quantity of sparks to send + /// @param referrer The referrer of the comment + function sparkComment(CommentIdentifier calldata commentIdentifier, uint256 sparksQuantity, address referrer) external payable; + + /// @notice Returns the value of a single spark + /// @return The value of a single spark + function sparkValue() external view returns (uint256); + + /// @notice Hashes a comment identifier to generate a unique ID + /// @param commentIdentifier The comment identifier to hash + /// @return The hashed comment identifier + function hashCommentIdentifier(CommentIdentifier calldata commentIdentifier) external view returns (bytes32); + + /// @notice Returns the next nonce for comment creation + /// @return The next nonce + function nextNonce() external view returns (bytes32); + + /// @notice Returns the implementation address of the contract + /// @return The implementation address + function implementation() external view returns (address); + + /// @notice Returns the total number of sparks a given comment has received + /// @param commentIdentifier The identifier of the comment + /// @return The total number of sparks a comment has received + function commentSparksQuantity(CommentIdentifier memory commentIdentifier) external view returns (uint256); + + /// @notice Hashes a comment identifier and checks if a comment exists with that id + /// @param commentIdentifier The comment identifier to check + /// @return commentId The hashed comment identifier + /// @return exists Whether the comment exists + function hashAndCheckCommentExists(CommentIdentifier memory commentIdentifier) external view returns (bytes32 commentId, bool exists); + + /// @notice Validates that a comment exists and returns its ID + /// @param commentIdentifier The comment identifier to validate + /// @return commentId The hashed comment identifier + function hashAndValidateCommentExists(CommentIdentifier memory commentIdentifier) external view returns (bytes32 commentId); + + /// @notice Hashes a permit comment struct for signing + /// @param permit The permit comment struct + /// @return The hash to sign + function hashPermitComment(PermitComment calldata permit) external view returns (bytes32); + + /// @notice Creates a comment on behalf of another account using a signed message + /// @param permit The permit that was signed off-chain on the source chain + /// @param signature The signature of the permit comment + function permitComment(PermitComment calldata permit, bytes calldata signature) external payable; + + /// @notice Hashes a permit spark comment struct for signing + /// @param permit The permit spark comment struct + /// @return The hash to sign + function hashPermitSparkComment(PermitSparkComment calldata permit) external view returns (bytes32); + + /// @notice Sparks a comment on behalf of another account using a signed message + /// @param permit The permit spark comment struct + /// @param signature The signature of the permit + function permitSparkComment(PermitSparkComment calldata permit, bytes calldata signature) external payable; + + /// @notice Backfills comments created by other contracts + /// @param commentIdentifiers Array of comment identifiers + /// @param texts Array of comment texts + /// @param timestamps Array of comment timestamps + /// @param originalTransactionHashes Array of original transaction hashes + function backfillBatchAddComment( + CommentIdentifier[] calldata commentIdentifiers, + string[] calldata texts, + uint256[] calldata timestamps, + bytes32[] calldata originalTransactionHashes + ) external; +} diff --git a/packages/comments/src/interfaces/IMultiOwnable.sol b/packages/comments/src/interfaces/IMultiOwnable.sol new file mode 100644 index 000000000..58ca7154f --- /dev/null +++ b/packages/comments/src/interfaces/IMultiOwnable.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +/// Extracted interface from Coinbase Smart Wallet's MultiOwnable contract, +/// which we use to check if an address is an owner of the smart wallet +/// in tests without having to include the entire contract in the package +/// Original function can be seen here: https://github.com/coinbase/talaria/blob/main/contracts/src/MultiOwnable.sol +interface IMultiOwnable { + function isOwnerAddress(address account) external view returns (bool); +} diff --git a/packages/comments/src/interfaces/ISecondarySwap.sol b/packages/comments/src/interfaces/ISecondarySwap.sol new file mode 100644 index 000000000..2e6fbeb59 --- /dev/null +++ b/packages/comments/src/interfaces/ISecondarySwap.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +/* + + + ░░░░░░░░░░░░░░ + ░░▒▒░░░░░░░░░░░░░░░░░░░░ + ░░▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░ + ░░▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░░ + ░▓▓▒▒▒▒░░░░░░░░░░░░ ░░░░░░░ + ░▓▓▓▒▒▒▒░░░░░░░░░░░░ ░░░░░░░░ + ░▓▓▓▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░░░░ + ░▓▓▓▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░ + ░▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░ + ░▓▓▓▓▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░ + ░░▓▓▓▓▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░ + ░░▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒░░ + ░░▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ + ░░▓▓▓▓▓▓▓▓▓▓▓▓▒▒░░░ + + OURS TRULY, + + +*/ + +interface ISecondarySwap { + // function sell1155(address erc20zAddress, uint256 num1155ToSell, address payable recipient, uint256 minEthToAcquire, uint160 sqrtPriceLimitX96) external; + + function buy1155( + address erc20zAddress, + uint256 num1155ToBuy, + address payable recipient, + address payable excessRefundRecipient, + uint256 maxEthToSpend, + uint160 sqrtPriceLimitX96 + ) external payable; + + error SaleNotSet(); +} diff --git a/packages/comments/src/interfaces/IZoraCreator1155.sol b/packages/comments/src/interfaces/IZoraCreator1155.sol new file mode 100644 index 000000000..8edb94c48 --- /dev/null +++ b/packages/comments/src/interfaces/IZoraCreator1155.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import {IERC1155} from "@openzeppelin/contracts/interfaces/IERC1155.sol"; +import {IZoraCreator1155TypesV1} from "./IZoraCreator1155TypesV1.sol"; + +interface IZoraCreator1155 is IERC1155, IZoraCreator1155TypesV1 { + function isAdminOrRole(address user, uint256 tokenId, uint256 role) external view returns (bool); + + function getCreatorRewardRecipient(uint256 tokenId) external view returns (address); + + function supportsInterface(bytes4 interfaceId) external view returns (bool); + + function config() external view returns (address owner, uint96 __gap1, address payable fundsRecipient, uint96 __gap2, address transferHook, uint96 __gap3); + + function owner() external view returns (address); +} diff --git a/packages/comments/src/interfaces/IZoraCreator1155TypesV1.sol b/packages/comments/src/interfaces/IZoraCreator1155TypesV1.sol new file mode 100644 index 000000000..876d0336d --- /dev/null +++ b/packages/comments/src/interfaces/IZoraCreator1155TypesV1.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +/* + + + ░░░░░░░░░░░░░░ + ░░▒▒░░░░░░░░░░░░░░░░░░░░ + ░░▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░ + ░░▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░░ + ░▓▓▒▒▒▒░░░░░░░░░░░░ ░░░░░░░ + ░▓▓▓▒▒▒▒░░░░░░░░░░░░ ░░░░░░░░ + ░▓▓▓▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░░░░ + ░▓▓▓▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░ + ░▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░ + ░▓▓▓▓▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░ + ░░▓▓▓▓▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░ + ░░▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒░░ + ░░▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ + ░░▓▓▓▓▓▓▓▓▓▓▓▓▒▒░░░ + + OURS TRULY, + + */ + +/// Imagine. Mint. Enjoy. +/// @notice Interface for types used across the ZoraCreator1155 contract +/// @author @iainnash / @tbtstl +interface IZoraCreator1155TypesV1 { + /// @notice Used to store individual token data + struct TokenData { + string uri; + uint256 maxSupply; + uint256 totalMinted; + } + + /// @notice Used to store contract-level configuration + struct ContractConfig { + address owner; + uint96 __gap1; + address payable fundsRecipient; + uint96 __gap2; + address transferHook; + uint96 __gap3; + } +} diff --git a/packages/comments/src/interfaces/IZoraTimedSaleStrategy.sol b/packages/comments/src/interfaces/IZoraTimedSaleStrategy.sol new file mode 100644 index 000000000..b77b2b806 --- /dev/null +++ b/packages/comments/src/interfaces/IZoraTimedSaleStrategy.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +interface IZoraTimedSaleStrategy { + function mint(address mintTo, uint256 quantity, address collection, uint256 tokenId, address mintReferral, string calldata comment) external payable; + + /// @dev This is the SaleV1 style sale with a set end and start time and is used in both cases for storing key sale information + struct SaleStorage { + /// @notice The ERC20z address + address payable erc20zAddress; + /// @notice The sale start time + uint64 saleStart; + /// @notice The Uniswap pool address + address poolAddress; + /// @notice The sale end time + uint64 saleEnd; + /// @notice Boolean if the secondary market has been launched + bool secondaryActivated; + } + + /// @notice Returns the sale config for a given token + /// @param collection The collection address + /// @param tokenId The ID of the token to get the sale config for + function sale(address collection, uint256 tokenId) external view returns (SaleStorage memory); +} diff --git a/packages/comments/src/proxy/CallerAndCommenter.sol b/packages/comments/src/proxy/CallerAndCommenter.sol new file mode 100644 index 000000000..a7724a8ba --- /dev/null +++ b/packages/comments/src/proxy/CallerAndCommenter.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import {Enjoy} from "_imagine/Enjoy.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +/* + + + ░░░░░░░░░░░░░░ + ░░▒▒░░░░░░░░░░░░░░░░░░░░ + ░░▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░ + ░░▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░░ + ░▓▓▒▒▒▒░░░░░░░░░░░░ ░░░░░░░ + ░▓▓▓▒▒▒▒░░░░░░░░░░░░ ░░░░░░░░ + ░▓▓▓▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░░░░ + ░▓▓▓▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░ + ░▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░ + ░▓▓▓▓▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░ + ░░▓▓▓▓▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░ + ░░▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒░░ + ░░▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ + ░░▓▓▓▓▓▓▓▓▓▓▓▓▒▒░░░ + + OURS TRULY, + + + */ + +/// @title Zora Comments Proxy Contract +/// @notice This is the proxy contract for the CallerAndCommenter contract. +/// @dev Inherits from ERC1967Proxy to enable upgradeable functionality. +/// @notice Imagine. Mint. Enjoy. +/// @author @oveddan +contract CallerAndCommenter is Enjoy, ERC1967Proxy { + bytes32 internal immutable name; + + constructor(address _logic) ERC1967Proxy(_logic, "") { + // added to create unique bytecode for this contract + // so that it can be properly verified as its own contract. + name = keccak256("CallerAndCommenter"); + } +} diff --git a/packages/comments/src/proxy/Comments.sol b/packages/comments/src/proxy/Comments.sol new file mode 100644 index 000000000..5f61efe0d --- /dev/null +++ b/packages/comments/src/proxy/Comments.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import {Enjoy} from "_imagine/Enjoy.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +/* + + + ░░░░░░░░░░░░░░ + ░░▒▒░░░░░░░░░░░░░░░░░░░░ + ░░▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░ + ░░▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░░ + ░▓▓▒▒▒▒░░░░░░░░░░░░ ░░░░░░░ + ░▓▓▓▒▒▒▒░░░░░░░░░░░░ ░░░░░░░░ + ░▓▓▓▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░░░░ + ░▓▓▓▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░ + ░▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░ + ░▓▓▓▓▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░ + ░░▓▓▓▓▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░ + ░░▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒░░ + ░░▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ + ░░▓▓▓▓▓▓▓▓▓▓▓▓▒▒░░░ + + OURS TRULY, + + + */ + +/// @title Zora Comments Proxy Contract +/// @notice This is the proxy contract for the Zora Comments contract. +/// @dev Inherits from ERC1967Proxy to enable upgradeable functionality. +/// @notice Imagine. Mint. Enjoy. +/// @author @oveddan +contract Comments is Enjoy, ERC1967Proxy { + bytes32 internal immutable name; + + constructor(address _logic) ERC1967Proxy(_logic, "") { + // added to create unique bytecode for this contract + // so that it can be properly verified as its own contract. + name = keccak256("Comments"); + } +} diff --git a/packages/comments/src/utils/CallerAndCommenterImpl.sol b/packages/comments/src/utils/CallerAndCommenterImpl.sol new file mode 100644 index 000000000..9a6d62bee --- /dev/null +++ b/packages/comments/src/utils/CallerAndCommenterImpl.sol @@ -0,0 +1,376 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import {IComments} from "../interfaces/IComments.sol"; +import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; +import {EIP712UpgradeableWithChainId} from "./EIP712UpgradeableWithChainId.sol"; +import {UnorderedNoncesUpgradeable} from "@zoralabs/shared-contracts/utils/UnorderedNoncesUpgradeable.sol"; +import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; +import {IZoraTimedSaleStrategy} from "../interfaces/IZoraTimedSaleStrategy.sol"; +import {ICallerAndCommenter} from "../interfaces/ICallerAndCommenter.sol"; +import {ContractVersionBase} from "../version/ContractVersionBase.sol"; +import {IHasContractName} from "@zoralabs/shared-contracts/interfaces/IContractMetadata.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; +import {ISecondarySwap} from "../interfaces/ISecondarySwap.sol"; +import {IERC1155} from "@openzeppelin/contracts/interfaces/IERC1155.sol"; + +/// @title Calls contracts and allows a user to add a comment to be associated with the call. +/// @author oveddan +/// @dev Upgradeable contract. Is given permission to delegateComment on the Comments contract, +/// meaning it is trusted to indicate who a comment is from. +contract CallerAndCommenterImpl is + ICallerAndCommenter, + Ownable2StepUpgradeable, + EIP712UpgradeableWithChainId, + UnorderedNoncesUpgradeable, + ContractVersionBase, + UUPSUpgradeable, + IHasContractName +{ + IComments public immutable comments; + IZoraTimedSaleStrategy public immutable zoraTimedSale; + ISecondarySwap public immutable secondarySwap; + uint256 public immutable sparkValue; + + IComments.CommentIdentifier internal emptyCommentIdentifier; + + string constant DOMAIN_NAME = "CallerAndCommenter"; + string constant DOMAIN_VERSION = "1"; + + IComments.CommentIdentifier internal emptyReplyTo; + + constructor(address _comments, address _zoraTimedSale, address _swapHelper, uint256 _sparksValue) { + comments = IComments(_comments); + zoraTimedSale = IZoraTimedSaleStrategy(_zoraTimedSale); + secondarySwap = ISecondarySwap(_swapHelper); + sparkValue = _sparksValue; + _disableInitializers(); + } + + /// Initializes the upgradeable contract + /// @param owner of the contract that can perform upgrades + function initialize(address owner) external initializer { + __Ownable_init(owner); + __UUPSUpgradeable_init(); + __EIP712_init(DOMAIN_NAME, DOMAIN_VERSION); + } + + /// @notice Mints tokens and adds a comment, without needing to pay a spark for the comment. + /// @dev The payable amount should be the total mint fee. No spark value should be sent. + /// @param quantity The number of tokens to mint + /// @param collection The address of the 1155 collection to mint from + /// @param tokenId The 1155 token Id to mint + /// @param mintReferral The address to receive mint referral rewards, if any + /// @param comment The comment to be added. If empty, no comment will be added. + /// @return The identifier of the newly created comment + function timedSaleMintAndComment( + address commenter, + uint256 quantity, + address collection, + uint256 tokenId, + address mintReferral, + string calldata comment + ) external payable returns (IComments.CommentIdentifier memory) { + if (commenter != msg.sender) { + revert CommenterMismatch(msg.sender, commenter); + } + + return _timedSaleMintAndComment(commenter, quantity, collection, tokenId, mintReferral, comment); + } + + function _timedSaleMintAndComment( + address commenter, + uint256 quantity, + address collection, + uint256 tokenId, + address mintReferral, + string calldata comment + ) internal returns (IComments.CommentIdentifier memory commentIdentifier) { + zoraTimedSale.mint{value: msg.value}(commenter, quantity, collection, tokenId, mintReferral, ""); + + if (bytes(comment).length > 0) { + bytes32 commentId; + (commentIdentifier, commentId) = comments.delegateComment(commenter, collection, tokenId, comment, emptyReplyTo, address(0), address(0)); + + emit MintedAndCommented(commentId, commentIdentifier, quantity, comment); + } + } + + bytes32 constant PERMIT_TIMED_SALE_MINT_AND_COMMENT_DOMAIN = + keccak256( + "PermitTimedSaleMintAndComment(address commenter,uint256 quantity,address collection,uint256 tokenId,address mintReferral,string comment,uint256 deadline,bytes32 nonce,uint32 sourceChainId,uint32 destinationChainId)" + ); + + /// @notice Hashes the permit data for a timed sale mint and comment operation + /// @param permit The PermitTimedSaleMintAndComment struct containing the permit data + /// @return bytes32 The hash of the permit data for signing + function hashPermitTimedSaleMintAndComment(PermitTimedSaleMintAndComment memory permit) public view returns (bytes32) { + bytes32 structHash = keccak256( + abi.encode( + PERMIT_TIMED_SALE_MINT_AND_COMMENT_DOMAIN, + permit.commenter, + permit.quantity, + permit.collection, + permit.tokenId, + permit.mintReferral, + keccak256(bytes(permit.comment)), + permit.deadline, + permit.nonce, + permit.sourceChainId, + permit.destinationChainId + ) + ); + + return _hashTypedDataV4(structHash, permit.sourceChainId); + } + + function _validateSignature(bytes32 digest, bytes calldata signature, address signer) internal view { + if (!SignatureChecker.isValidSignatureNow(signer, digest, signature)) { + revert InvalidSignature(); + } + } + + function _validateAndUsePermit( + bytes32 digest, + bytes32 nonce, + bytes calldata signature, + address signer, + uint256 deadline, + uint32 destinationChainId + ) internal { + if (block.timestamp > deadline) { + revert ERC2612ExpiredSignature(deadline); + } + + if (destinationChainId != uint32(block.chainid)) { + revert IncorrectDestinationChain(destinationChainId); + } + + _useCheckedNonce(signer, nonce); + _validateSignature(digest, signature, signer); + } + + /// @notice Mints tokens and adds a comment, without needing to pay a spark for the comment. Attributes the + /// comment to the signer of the message. Meant to be used for cross-chain commenting. where a permit + /// @dev The signer must match the commenter field in the permit. + /// @param permit The PermitTimedSaleMintAndComment struct containing the permit data + /// @param signature The signature of the permit + /// @return The identifier of the newly created comment + function permitTimedSaleMintAndComment( + PermitTimedSaleMintAndComment calldata permit, + bytes calldata signature + ) public payable returns (IComments.CommentIdentifier memory) { + bytes32 digest = hashPermitTimedSaleMintAndComment(permit); + + _validateAndUsePermit(digest, permit.nonce, signature, permit.commenter, permit.deadline, permit.destinationChainId); + + return _timedSaleMintAndComment(permit.commenter, permit.quantity, permit.collection, permit.tokenId, permit.mintReferral, permit.comment); + } + + /// @notice Buys Zora 1155 tokens on secondary market and adds a comment, without needing to pay a spark for the comment. + /// @param commenter The address of the commenter. Must match the msg.sender. Commenter will be the recipient of the bought tokens. + /// @param quantity The number of tokens to buy + /// @param collection The address of the 1155 collection + /// @param tokenId The 1155 token Id to buy + /// @param excessRefundRecipient The address to receive any excess ETH refund + /// @param maxEthToSpend The maximum amount of ETH to spend on the purchase + /// @param sqrtPriceLimitX96 The sqrt price limit for the swap + /// @param comment The comment to be added + /// @return The identifier of the newly created comment + /// @dev This function can only be called by the commenter themselves + function buyOnSecondaryAndComment( + address commenter, + uint256 quantity, + address collection, + uint256 tokenId, + address payable excessRefundRecipient, + uint256 maxEthToSpend, + uint160 sqrtPriceLimitX96, + string calldata comment + ) external payable returns (IComments.CommentIdentifier memory) { + if (commenter != msg.sender) { + revert CommenterMismatch(msg.sender, commenter); + } + + return _buyOnSecondaryAndComment(commenter, quantity, collection, tokenId, excessRefundRecipient, maxEthToSpend, sqrtPriceLimitX96, comment); + } + + function _buyOnSecondaryAndComment( + address commenter, + uint256 quantity, + address collection, + uint256 tokenId, + address payable excessRefundRecipient, + uint256 maxEthToSpend, + uint160 sqrtPriceLimitX96, + string calldata comment + ) internal returns (IComments.CommentIdentifier memory commentIdentifier) { + address erc20zAddress = zoraTimedSale.sale(collection, tokenId).erc20zAddress; + if (erc20zAddress == address(0)) { + revert SaleNotSet(collection, tokenId); + } + + secondarySwap.buy1155{value: msg.value}({ + erc20zAddress: erc20zAddress, + num1155ToBuy: quantity, + recipient: payable(commenter), + excessRefundRecipient: excessRefundRecipient, + maxEthToSpend: maxEthToSpend, + sqrtPriceLimitX96: sqrtPriceLimitX96 + }); + + if (bytes(comment).length > 0) { + bytes32 commentId; + (commentIdentifier, commentId) = comments.delegateComment(commenter, collection, tokenId, comment, emptyReplyTo, address(0), address(0)); + + emit SwappedOnSecondaryAndCommented(commentId, commentIdentifier, quantity, comment, SwapDirection.BUY); + } + + return commentIdentifier; + } + + bytes32 constant PERMIT_BUY_ON_SECONDARY_AND_COMMENT_DOMAIN = + keccak256( + "PermitBuyOnSecondaryAndComment(address commenter,uint256 quantity,address collection,uint256 tokenId,uint256 maxEthToSpend,uint160 sqrtPriceLimitX96,string comment,uint256 deadline,bytes32 nonce,uint32 sourceChainId,uint32 destinationChainId)" + ); + + function _hashPermitBuyOnSecondaryAndComment(PermitBuyOnSecondaryAndComment memory permit) internal pure returns (bytes memory) { + return + abi.encode( + PERMIT_BUY_ON_SECONDARY_AND_COMMENT_DOMAIN, + permit.commenter, + permit.quantity, + permit.collection, + permit.tokenId, + permit.maxEthToSpend, + permit.sqrtPriceLimitX96, + keccak256(bytes(permit.comment)), + permit.deadline, + permit.nonce, + permit.sourceChainId, + permit.destinationChainId + ); + } + + /// @notice Hashes the permit data for a buy on secondary and comment operation + /// @param permit The PermitBuyOnSecondaryAndComment struct containing the permit data + /// @return bytes32 The hash of the permit data for signing + function hashPermitBuyOnSecondaryAndComment(PermitBuyOnSecondaryAndComment memory permit) public view returns (bytes32) { + return _hashTypedDataV4(keccak256(_hashPermitBuyOnSecondaryAndComment(permit)), permit.sourceChainId); + } + + /// @notice Buys tokens on secondary market and adds a comment, without needing to pay a spark for the comment. Attributes the + /// comment to the signer of the message. Meant to be used for cross-chain commenting where a permit is used. + /// @dev The signer must match the commenter field in the permit. + /// @param permit The PermitBuyOnSecondaryAndComment struct containing the permit data + /// @param signature The signature of the permit + /// @return The identifier of the newly created comment + function permitBuyOnSecondaryAndComment( + PermitBuyOnSecondaryAndComment calldata permit, + bytes calldata signature + ) public payable returns (IComments.CommentIdentifier memory) { + bytes32 digest = hashPermitBuyOnSecondaryAndComment(permit); + + _validateAndUsePermit(digest, permit.nonce, signature, permit.commenter, permit.deadline, permit.destinationChainId); + + return + _buyOnSecondaryAndComment( + permit.commenter, + permit.quantity, + permit.collection, + permit.tokenId, + payable(permit.commenter), + permit.maxEthToSpend, + permit.sqrtPriceLimitX96, + permit.comment + ); + } + + /// @notice Sells Zora 1155 tokens on secondary market and adds a comment. + /// @dev Must sent ETH value of one spark for the comment. Commenter must have approved this contract to transfer the tokens + /// on the 1155 contract. + /// @param commenter The address of the commenter. Must match the msg.sender. Commenter will be the seller of the tokens. + /// @param quantity The number of tokens to sell + /// @param collection The address of the 1155 collection + /// @param tokenId The 1155 token Id to sell + /// @param recipient The address to receive the ETH proceeds + /// @param minEthToAcquire The minimum amount of ETH to receive from the sale + /// @param sqrtPriceLimitX96 The sqrt price limit for the swap + /// @param comment The comment to be added + /// @return commentIdentifier The identifier of the newly created comment + /// @dev This function can only be called by the commenter themselves + function sellOnSecondaryAndComment( + address commenter, + uint256 quantity, + address collection, + uint256 tokenId, + address payable recipient, + uint256 minEthToAcquire, + uint160 sqrtPriceLimitX96, + string calldata comment + ) external payable returns (IComments.CommentIdentifier memory commentIdentifier) { + if (commenter != msg.sender) { + revert CommenterMismatch(msg.sender, commenter); + } + + if (bytes(comment).length == 0) { + // if we are not sending a comment, we should not send any ETH + if (msg.value != 0) { + revert WrongValueSent(0, msg.value); + } + } else { + // if we are sending a comment, we should be required to send one spark + if (msg.value != sparkValue) { + revert WrongValueSent(sparkValue, msg.value); + } + + bytes32 commentId; + // submit the comment, attaching the spark value if it is sent + (commentIdentifier, commentId) = comments.delegateComment{value: msg.value}( + commenter, + collection, + tokenId, + comment, + emptyReplyTo, + address(0), + address(0) + ); + + emit SwappedOnSecondaryAndCommented(commentId, commentIdentifier, quantity, comment, SwapDirection.SELL); + } + + // wrapped around brackets to prevent stack too deep error + { + // transfer the tokens to the secondary swap + IERC1155(collection).safeTransferFrom( + // transferring from the commenter to the secondary swap contract. + // commenter must have approved this contract to transfer tokens. + address(commenter), + address(secondarySwap), + tokenId, + quantity, + abi.encode(recipient, minEthToAcquire, sqrtPriceLimitX96) + ); + } + + return commentIdentifier; + } + + /// @notice Returns the name of the contract + /// @return The name of the contract + function contractName() public pure returns (string memory) { + return "Caller and Commenter"; + } + + function _authorizeUpgrade(address newImplementation) internal override onlyOwner { + // check that new implementation's contract name matches the current contract name + if (!Strings.equal(IHasContractName(newImplementation).contractName(), this.contractName())) { + revert UpgradeToMismatchedContractName(this.contractName(), IHasContractName(newImplementation).contractName()); + } + } + + function _equals(string memory a, string memory b) internal pure returns (bool) { + return (keccak256(bytes(a)) == keccak256(bytes(b))); + } +} diff --git a/packages/comments/src/utils/EIP712UpgradeableWithChainId.sol b/packages/comments/src/utils/EIP712UpgradeableWithChainId.sol new file mode 100644 index 000000000..d61500498 --- /dev/null +++ b/packages/comments/src/utils/EIP712UpgradeableWithChainId.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {EIP712Upgradeable} from "@openzeppelin/contracts-upgradeable/utils/cryptography/EIP712Upgradeable.sol"; +import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; + +/// Extension of EIP712Upgradeable that allows for messages to be signed on other chains. +abstract contract EIP712UpgradeableWithChainId is EIP712Upgradeable { + bytes32 private constant TYPE_HASH = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); + + /** + * @dev Returns the domain separator for the current chain. + */ + function _domainSeparatorV4(uint256 chainId) internal view returns (bytes32) { + return _buildDomainSeparator(chainId); + } + + function _buildDomainSeparator(uint256 chainId) private view returns (bytes32) { + return keccak256(abi.encode(TYPE_HASH, _EIP712NameHash(), _EIP712VersionHash(), chainId, address(this))); + } + + function _hashTypedDataV4(bytes32 structHash, uint256 chainId) internal view returns (bytes32) { + return MessageHashUtils.toTypedDataHash(_domainSeparatorV4(chainId), structHash); + } +} diff --git a/packages/comments/src/version/ContractVersionBase.sol b/packages/comments/src/version/ContractVersionBase.sol new file mode 100644 index 000000000..1f4692dc4 --- /dev/null +++ b/packages/comments/src/version/ContractVersionBase.sol @@ -0,0 +1,14 @@ +// This file is automatically generated by code; do not manually update +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import {IVersionedContract} from "@zoralabs/shared-contracts/interfaces/IVersionedContract.sol"; + +/// @title ContractVersionBase +/// @notice Base contract for versioning contracts +contract ContractVersionBase is IVersionedContract { + /// @notice The version of the contract + function contractVersion() external pure override returns (string memory) { + return "0.0.2"; + } +} diff --git a/packages/comments/test/CallerAndCommenterTestBase.sol b/packages/comments/test/CallerAndCommenterTestBase.sol new file mode 100644 index 000000000..f32997d21 --- /dev/null +++ b/packages/comments/test/CallerAndCommenterTestBase.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; + +import {CallerAndCommenterImpl} from "../src/utils/CallerAndCommenterImpl.sol"; +import {CommentsImpl} from "../src/CommentsImpl.sol"; +import {Comments} from "../src/proxy/Comments.sol"; +import {IComments} from "../src/interfaces/IComments.sol"; +import {Mock1155} from "./mocks/Mock1155.sol"; +import {MockZoraTimedSale} from "./mocks/MockZoraTimedSale.sol"; +import {MockSecondarySwap} from "./mocks/MockSecondarySwap.sol"; +import {ICallerAndCommenter} from "../src/interfaces/ICallerAndCommenter.sol"; +import {CallerAndCommenter} from "../src/proxy/CallerAndCommenter.sol"; + +contract CallerAndCommenterTestBase is Test { + uint256 constant SPARKS_VALUE = 0.000001 ether; + + address zoraRecipient = makeAddr("zoraRecipient"); + address commentsAdmin = makeAddr("commentsAdmin"); + address commenter; + uint256 commenterPrivateKey; + address backfiller = makeAddr("backfiller"); + address tokenAdmin = makeAddr("tokenAdmin"); + address protocolRewards = 0x7777777F279eba3d3Ad8F4E708545291A6fDBA8B; + + IComments.CommentIdentifier emptyCommentIdentifier; + + MockSecondarySwap mockSecondarySwap; + + IComments comments; + + Mock1155 mock1155; + + uint256 tokenId1 = 1; + + MockZoraTimedSale mockMinter; + ICallerAndCommenter callerAndCommenter; + + function setUp() public { + vm.createSelectFork("zora_sepolia", 16028863); + + (commenter, commenterPrivateKey) = makeAddrAndKey("commenter"); + + mock1155 = new Mock1155(); + mock1155.createToken(tokenId1, tokenAdmin); + + CommentsImpl commentsImpl = new CommentsImpl(SPARKS_VALUE, protocolRewards, zoraRecipient); + + mockMinter = new MockZoraTimedSale(); + mockSecondarySwap = new MockSecondarySwap(mockMinter); + + comments = IComments(payable(address(new Comments(address(commentsImpl))))); + + CallerAndCommenterImpl callerAndCommenterImpl = new CallerAndCommenterImpl( + address(comments), + address(mockMinter), + address(mockSecondarySwap), + SPARKS_VALUE + ); + callerAndCommenter = ICallerAndCommenter(payable(address(new CallerAndCommenter(address(callerAndCommenterImpl))))); + + address[] memory delegateCommenters = new address[](1); + delegateCommenters[0] = address(callerAndCommenter); + + comments.initialize(commentsAdmin, backfiller, delegateCommenters); + callerAndCommenter.initialize(commentsAdmin); + } + + function _expectedCommentIdentifier( + address _commenter, + address contractAddress, + uint256 tokenId + ) internal view returns (IComments.CommentIdentifier memory) { + return IComments.CommentIdentifier({commenter: _commenter, contractAddress: contractAddress, tokenId: tokenId, nonce: comments.nextNonce()}); + } +} diff --git a/packages/comments/test/CallerAndCommenter_mintAndComment.t copy.sol b/packages/comments/test/CallerAndCommenter_mintAndComment.t copy.sol new file mode 100644 index 000000000..52a621c30 --- /dev/null +++ b/packages/comments/test/CallerAndCommenter_mintAndComment.t copy.sol @@ -0,0 +1,214 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IComments} from "../src/interfaces/IComments.sol"; +import {ICallerAndCommenter} from "../src/interfaces/ICallerAndCommenter.sol"; +import {CallerAndCommenterTestBase} from "./CallerAndCommenterTestBase.sol"; + +contract CallerAndCommenterMintAndCommentTest is CallerAndCommenterTestBase { + function testCanTimedSaleMintAndComment() public { + uint256 quantityToMint = 1; + uint256 mintFee = 0.000111 ether; + + address contractAddress = address(mock1155); + uint256 tokenId = tokenId1; + + IComments.CommentIdentifier memory expectedCommentIdentifier = _expectedCommentIdentifier(commenter, contractAddress, tokenId); + + bytes32 expectedCommentId = comments.hashCommentIdentifier(expectedCommentIdentifier); + bytes32 expectedReplyToId = bytes32(0); + + uint64 sparksQuantity = 0; + + address mintReferral = address(0); + + vm.deal(commenter, mintFee * quantityToMint); + vm.expectEmit(true, true, true, true); + emit IComments.Commented( + expectedCommentId, + expectedCommentIdentifier, + expectedReplyToId, + emptyCommentIdentifier, + sparksQuantity, + "test", + block.timestamp, + address(0) + ); + vm.expectEmit(true, true, true, true); + emit ICallerAndCommenter.MintedAndCommented(expectedCommentId, expectedCommentIdentifier, quantityToMint, "test"); + vm.prank(commenter); + callerAndCommenter.timedSaleMintAndComment{value: mintFee * quantityToMint}( + commenter, + quantityToMint, + address(mock1155), + tokenId1, + mintReferral, + "test" + ); + + // validate that the comment was created + (, bool exists) = comments.hashAndCheckCommentExists(expectedCommentIdentifier); + assertEq(exists, true); + // make sure mock 1155 got the full mint fee + assertEq(address(mock1155).balance, mintFee * quantityToMint); + } + + function testWhenNoCommentDoesNotComment() public { + uint256 quantityToMint = 1; + uint256 mintFee = 0.000111 ether; + + vm.deal(commenter, mintFee * quantityToMint); + vm.prank(commenter); + IComments.CommentIdentifier memory result = callerAndCommenter.timedSaleMintAndComment{value: mintFee * quantityToMint}( + commenter, + quantityToMint, + address(mock1155), + tokenId1, + address(0), + "" + ); + + assertEq(result.commenter, address(0)); + assertEq(result.contractAddress, address(0)); + assertEq(result.tokenId, 0); + assertEq(result.nonce, bytes32(0)); + } + + function testPermitTimedSaleMintAndComment() public { + uint256 quantityToMint = 1; + uint256 mintFee = 0.000111 ether; + + address contractAddress = address(mock1155); + uint256 tokenId = tokenId1; + + string memory comment = "test comment"; + + ICallerAndCommenter.PermitTimedSaleMintAndComment memory permit = _createPermit( + commenter, + quantityToMint, + contractAddress, + tokenId, + address(0), + comment, + block.timestamp + 1 hours + ); + + bytes memory signature = _signPermit(permit, commenterPrivateKey); + + IComments.CommentIdentifier memory expectedCommentIdentifier = _expectedCommentIdentifier(commenter, contractAddress, tokenId); + + bytes32 expectedCommentId = comments.hashCommentIdentifier(expectedCommentIdentifier); + + vm.deal(commenter, mintFee * quantityToMint); + vm.expectEmit(true, true, true, true); + emit IComments.Commented(expectedCommentId, expectedCommentIdentifier, bytes32(0), emptyCommentIdentifier, 0, comment, block.timestamp, address(0)); + vm.expectEmit(true, true, true, true); + emit ICallerAndCommenter.MintedAndCommented(expectedCommentId, expectedCommentIdentifier, quantityToMint, comment); + IComments.CommentIdentifier memory result = callerAndCommenter.permitTimedSaleMintAndComment{value: mintFee * quantityToMint}(permit, signature); + + assertEq(result.commenter, commenter); + assertEq(result.contractAddress, contractAddress); + assertEq(result.tokenId, tokenId); + assertEq(result.nonce, bytes32(0)); + + // validate that the comment was created + (, bool exists) = comments.hashAndCheckCommentExists(expectedCommentIdentifier); + assertEq(exists, true); + // make sure mock 1155 got the full mint fee + assertEq(address(mock1155).balance, mintFee * quantityToMint); + } + + function testPermitTimedSaleMintAndComment_ExpiredDeadline() public { + uint256 quantityToMint = 1; + uint256 mintFee = 0.000111 ether; + + ICallerAndCommenter.PermitTimedSaleMintAndComment memory permit = _createPermit( + commenter, + quantityToMint, + address(mock1155), + tokenId1, + address(0), + "test comment", + block.timestamp - 1 // Expired deadline + ); + + bytes memory signature = _signPermit(permit, commenterPrivateKey); + + vm.deal(commenter, mintFee * quantityToMint); + vm.expectRevert(abi.encodeWithSelector(IComments.ERC2612ExpiredSignature.selector, permit.deadline)); + callerAndCommenter.permitTimedSaleMintAndComment{value: mintFee * quantityToMint}(permit, signature); + } + + function testPermitTimedSaleMintAndComment_InvalidSignature() public { + uint256 quantityToMint = 1; + uint256 mintFee = 0.000111 ether; + + ICallerAndCommenter.PermitTimedSaleMintAndComment memory permit = _createPermit( + commenter, + quantityToMint, + address(mock1155), + tokenId1, + address(0), + "test comment", + block.timestamp + 1 hours + ); + + bytes memory signature = _signPermit(permit, 5); // Wrong signer + + vm.deal(commenter, mintFee * quantityToMint); + vm.expectRevert(IComments.InvalidSignature.selector); + callerAndCommenter.permitTimedSaleMintAndComment{value: mintFee * quantityToMint}(permit, signature); + } + + function testPermitTimedSaleMintAndComment_IncorrectDestinationChain() public { + uint256 quantityToMint = 1; + uint256 mintFee = 0.000111 ether; + + ICallerAndCommenter.PermitTimedSaleMintAndComment memory permit = _createPermit( + commenter, + quantityToMint, + address(mock1155), + tokenId1, + address(0), + "test comment", + block.timestamp + 1 hours + ); + permit.destinationChainId = uint32(block.chainid) + 1; // Incorrect destination chain + + bytes memory signature = _signPermit(permit, commenterPrivateKey); + + vm.deal(commenter, mintFee * quantityToMint); + vm.expectRevert(abi.encodeWithSelector(ICallerAndCommenter.IncorrectDestinationChain.selector, permit.destinationChainId)); + callerAndCommenter.permitTimedSaleMintAndComment{value: mintFee * quantityToMint}(permit, signature); + } + + function _createPermit( + address _commenter, + uint256 _quantity, + address _collection, + uint256 _tokenId, + address _mintReferral, + string memory _comment, + uint256 _deadline + ) internal view returns (ICallerAndCommenter.PermitTimedSaleMintAndComment memory) { + return + ICallerAndCommenter.PermitTimedSaleMintAndComment({ + commenter: _commenter, + quantity: _quantity, + collection: _collection, + tokenId: _tokenId, + mintReferral: _mintReferral, + comment: _comment, + deadline: _deadline, + nonce: bytes32(0), + sourceChainId: uint32(block.chainid), + destinationChainId: uint32(block.chainid) + }); + } + + function _signPermit(ICallerAndCommenter.PermitTimedSaleMintAndComment memory _permit, uint256 _privateKey) internal view returns (bytes memory) { + bytes32 digest = callerAndCommenter.hashPermitTimedSaleMintAndComment(_permit); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(_privateKey, digest); + return abi.encodePacked(r, s, v); + } +} diff --git a/packages/comments/test/CallerAndCommenter_swapAndComment.t.sol b/packages/comments/test/CallerAndCommenter_swapAndComment.t.sol new file mode 100644 index 000000000..c9932a36f --- /dev/null +++ b/packages/comments/test/CallerAndCommenter_swapAndComment.t.sol @@ -0,0 +1,523 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IComments} from "../src/interfaces/IComments.sol"; +import {ICallerAndCommenter} from "../src/interfaces/ICallerAndCommenter.sol"; +import {CallerAndCommenterTestBase} from "./CallerAndCommenterTestBase.sol"; +import {ISecondarySwap} from "../src/interfaces/ISecondarySwap.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {CallerAndCommenterImpl} from "../src/utils/CallerAndCommenterImpl.sol"; +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import {IERC1155} from "@openzeppelin/contracts/interfaces/IERC1155.sol"; +import {IERC1155Receiver} from "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol"; + +contract CallerAndCommenterSwapAndCommentTest is CallerAndCommenterTestBase { + function testBuyOnSecondaryAndComment() public { + // setup the sale so that we have a link between the erc20z and the 1155 + address erc20z = mockMinter.setSale(address(mock1155), tokenId1); + + uint256 quantity = 5; + + address excessRefundRecipient = makeAddr("excessRefundRecipient"); + uint256 maxEthToSpend = 2 ether; + uint160 sqrtPriceLimitX96 = 1000; + string memory comment = "test comment"; + + IComments.CommentIdentifier memory expectedCommentIdentifier = _expectedCommentIdentifier(commenter, address(mock1155), tokenId1); + bytes32 expectedCommentId = comments.hashCommentIdentifier(expectedCommentIdentifier); + + uint256 valueToSpend = 1 ether; + vm.deal(commenter, valueToSpend); + + vm.expectEmit(true, true, true, true); + emit IComments.Commented(expectedCommentId, expectedCommentIdentifier, bytes32(0), emptyCommentIdentifier, 0, comment, block.timestamp, address(0)); + vm.expectEmit(true, true, true, true); + emit ICallerAndCommenter.SwappedOnSecondaryAndCommented( + expectedCommentId, + expectedCommentIdentifier, + quantity, + comment, + ICallerAndCommenter.SwapDirection.BUY + ); + + vm.expectCall( + address(mockSecondarySwap), + valueToSpend, + abi.encodeWithSelector(ISecondarySwap.buy1155.selector, erc20z, quantity, commenter, excessRefundRecipient, maxEthToSpend, sqrtPriceLimitX96) + ); + + vm.prank(commenter); + callerAndCommenter.buyOnSecondaryAndComment{value: valueToSpend}({ + commenter: commenter, + quantity: quantity, + collection: address(mock1155), + tokenId: tokenId1, + excessRefundRecipient: payable(excessRefundRecipient), + maxEthToSpend: maxEthToSpend, + sqrtPriceLimitX96: sqrtPriceLimitX96, + comment: comment + }); + } + + function testBuyOnSecondaryAndComment_revertsWhenSaleNotSet() public { + vm.expectRevert(abi.encodeWithSelector(ICallerAndCommenter.SaleNotSet.selector, address(mock1155), tokenId1)); + vm.prank(commenter); + callerAndCommenter.buyOnSecondaryAndComment({ + commenter: commenter, + quantity: 1, + collection: address(mock1155), + tokenId: tokenId1, + excessRefundRecipient: payable(address(0)), + maxEthToSpend: 0, + sqrtPriceLimitX96: 0, + comment: "test comment" + }); + } + + function testBuyOnSecondaryAndComment_revertsWhenCommenterMismatch() public { + address commenter2 = makeAddr("commenter2"); + vm.expectRevert(abi.encodeWithSelector(ICallerAndCommenter.CommenterMismatch.selector, commenter2, commenter)); + vm.prank(commenter2); + callerAndCommenter.buyOnSecondaryAndComment({ + commenter: commenter, + quantity: 1, + collection: address(mock1155), + tokenId: tokenId1, + excessRefundRecipient: payable(address(0)), + maxEthToSpend: 0, + sqrtPriceLimitX96: 0, + comment: "test comment" + }); + } + + function testBuyOnSecondaryAndCommentWhenNoCommentDoesNotComment() public { + uint256 quantity = 1; + uint256 maxEthToSpend = 0.2 ether; + uint160 sqrtPriceLimitX96 = 0; + address excessRefundRecipient = address(0); + + mockMinter.setSale(address(mock1155), tokenId1); + + vm.prank(commenter); + IComments.CommentIdentifier memory result = callerAndCommenter.buyOnSecondaryAndComment({ + commenter: commenter, + quantity: quantity, + collection: address(mock1155), + tokenId: tokenId1, + excessRefundRecipient: payable(excessRefundRecipient), + maxEthToSpend: maxEthToSpend, + sqrtPriceLimitX96: sqrtPriceLimitX96, + comment: "" + }); + + assertEq(result.commenter, address(0)); + assertEq(result.contractAddress, address(0)); + assertEq(result.tokenId, 0); + assertEq(result.nonce, bytes32(0)); + } + + function upgradeForkCallerAndCommenterToNewImplementation() internal { + // upgrade the current caller and commenter on the fork to the new implementation + address COMMENTS = 0x7777777C2B3132e03a65721a41745C07170a5877; + address ZORA_TIMED_SALE_STRATEGY = 0x777777722D078c97c6ad07d9f36801e653E356Ae; + address SECONDARY_SWAP = 0x777777EDF27Ac61671e3D5718b10bf6a8802f9f1; + // deploy the new implementation + CallerAndCommenterImpl callerAndCallerImp = new CallerAndCommenterImpl(COMMENTS, ZORA_TIMED_SALE_STRATEGY, SECONDARY_SWAP, SPARKS_VALUE); + // here we override the caller and commenter with the current one on the fork + callerAndCommenter = ICallerAndCommenter(0x77777775C5074b74540d9cC63Dd840A8c692B4B5); + // upgrade to the new implementation + address owner = OwnableUpgradeable(address(callerAndCommenter)).owner(); + vm.prank(owner); + UUPSUpgradeable(address(callerAndCommenter)).upgradeToAndCall(address(callerAndCallerImp), ""); + } + + function testFork_buyOnSecondaryAndComment() public { + // upgrade the forked caller and commenter to the new implementation + upgradeForkCallerAndCommenterToNewImplementation(); + // this is a known zora test collection where we can secondary swap + address test1155Address = 0xE79585bF83BbBfAE0fB80222b0a72F2c1D040612; + uint256 testTokenId = 1; + + address excessRefundRecipient = makeAddr("excessRefundRecipient"); + uint256 maxEthToSpend = 237222215770897; + uint160 sqrtPriceLimitX96 = 0; + string memory comment = "test comment"; + + IComments.CommentIdentifier memory expectedCommentIdentifier = IComments.CommentIdentifier({ + commenter: commenter, + contractAddress: test1155Address, + tokenId: testTokenId, + // we need to get the nonce from the fork comments contract + nonce: callerAndCommenter.comments().nextNonce() + }); + bytes32 expectedCommentId = callerAndCommenter.comments().hashCommentIdentifier(expectedCommentIdentifier); + + uint256 quantity = 1; + uint256 valueToSpend = maxEthToSpend; + vm.deal(commenter, valueToSpend); + + vm.expectEmit(true, true, true, true); + emit IComments.Commented(expectedCommentId, expectedCommentIdentifier, bytes32(0), emptyCommentIdentifier, 0, comment, block.timestamp, address(0)); + vm.expectEmit(true, true, true, true); + emit ICallerAndCommenter.SwappedOnSecondaryAndCommented( + expectedCommentId, + expectedCommentIdentifier, + quantity, + comment, + ICallerAndCommenter.SwapDirection.BUY + ); + + // call the caller and commenter contract + vm.prank(commenter); + callerAndCommenter.buyOnSecondaryAndComment{value: valueToSpend}({ + commenter: commenter, + quantity: quantity, + collection: test1155Address, + tokenId: testTokenId, + excessRefundRecipient: payable(excessRefundRecipient), + maxEthToSpend: maxEthToSpend, + sqrtPriceLimitX96: sqrtPriceLimitX96, + comment: comment + }); + } + + function testPermitBuyOnSecondaryAndComment() public { + uint256 quantity = 5; + uint256 valueToSpend = 1 ether; + + address contractAddress = address(mock1155); + uint256 tokenId = tokenId1; + + string memory comment = "test comment"; + + address erc20z = mockMinter.setSale(contractAddress, tokenId); + + ICallerAndCommenter.PermitBuyOnSecondaryAndComment memory permit = _createPermitBuy( + commenter, + quantity, + contractAddress, + tokenId, + 2 ether, + 1000, + comment, + block.timestamp + 1 hours + ); + + bytes memory signature = _signPermit(permit, commenterPrivateKey); + + IComments.CommentIdentifier memory expectedCommentIdentifier = _expectedCommentIdentifier(commenter, contractAddress, tokenId); + + bytes32 expectedCommentId = comments.hashCommentIdentifier(expectedCommentIdentifier); + + vm.deal(commenter, valueToSpend); + vm.expectEmit(true, true, true, true); + emit IComments.Commented(expectedCommentId, expectedCommentIdentifier, bytes32(0), emptyCommentIdentifier, 0, comment, block.timestamp, address(0)); + vm.expectEmit(true, true, true, true); + emit ICallerAndCommenter.SwappedOnSecondaryAndCommented( + expectedCommentId, + expectedCommentIdentifier, + quantity, + comment, + ICallerAndCommenter.SwapDirection.BUY + ); + + vm.expectCall( + address(mockSecondarySwap), + valueToSpend, + abi.encodeWithSelector( + ISecondarySwap.buy1155.selector, + erc20z, + quantity, + commenter, + permit.commenter, + permit.maxEthToSpend, + permit.sqrtPriceLimitX96 + ) + ); + + IComments.CommentIdentifier memory result = callerAndCommenter.permitBuyOnSecondaryAndComment{value: valueToSpend}(permit, signature); + + assertEq(result.commenter, commenter); + assertEq(result.contractAddress, contractAddress); + assertEq(result.tokenId, tokenId); + assertEq(result.nonce, bytes32(0)); + + // validate that the comment was created + (, bool exists) = comments.hashAndCheckCommentExists(expectedCommentIdentifier); + assertEq(exists, true); + } + + function testPermitBuyOnSecondaryAndComment_ExpiredDeadline() public { + ICallerAndCommenter.PermitBuyOnSecondaryAndComment memory permit = _createPermitBuy( + commenter, + 1, + address(mock1155), + tokenId1, + 1 ether, + 1000, + "test comment", + block.timestamp - 1 // Expired deadline + ); + + bytes memory signature = _signPermit(permit, commenterPrivateKey); + + vm.expectRevert(abi.encodeWithSelector(IComments.ERC2612ExpiredSignature.selector, permit.deadline)); + callerAndCommenter.permitBuyOnSecondaryAndComment{value: 1 ether}(permit, signature); + } + + function testPermitBuyOnSecondaryAndComment_InvalidSignature() public { + ICallerAndCommenter.PermitBuyOnSecondaryAndComment memory permit = _createPermitBuy( + commenter, + 1, + address(mock1155), + tokenId1, + 1 ether, + 1000, + "test comment", + block.timestamp + 1 hours + ); + + bytes memory signature = _signPermit(permit, 5); // Wrong signer + + vm.expectRevert(IComments.InvalidSignature.selector); + callerAndCommenter.permitBuyOnSecondaryAndComment{value: 1 ether}(permit, signature); + } + + function testPermitBuyOnSecondaryAndComment_IncorrectDestinationChain() public { + ICallerAndCommenter.PermitBuyOnSecondaryAndComment memory permit = _createPermitBuy( + commenter, + 1, + address(mock1155), + tokenId1, + 1 ether, + 1000, + "test comment", + block.timestamp + 1 hours + ); + permit.destinationChainId = uint32(block.chainid) + 1; // Incorrect destination chain + + bytes memory signature = _signPermit(permit, commenterPrivateKey); + + vm.expectRevert(abi.encodeWithSelector(ICallerAndCommenter.IncorrectDestinationChain.selector, permit.destinationChainId)); + callerAndCommenter.permitBuyOnSecondaryAndComment{value: 1 ether}(permit, signature); + } + + function testSellOnSecondaryAndComment() public { + // setup the sale so that we have a link between the erc20z and the 1155 + mockMinter.setSale(address(mock1155), tokenId1); + + uint256 quantityToSwap = 5; + mock1155.mint(commenter, tokenId1, quantityToSwap, ""); + + address payable recipient = payable(makeAddr("recipient")); + uint256 minEthToAcquire = 1 ether; + uint160 sqrtPriceLimitX96 = 1000; + string memory comment = "test comment"; + + IComments.CommentIdentifier memory expectedCommentIdentifier = _expectedCommentIdentifier(commenter, address(mock1155), tokenId1); + bytes32 expectedCommentId = comments.hashCommentIdentifier(expectedCommentIdentifier); + + bytes memory expectedData = abi.encode(recipient, minEthToAcquire, sqrtPriceLimitX96); + + // commenter needs to approve the caller to transfer the 1155s + // since it is to transfer to the secondary swap + vm.prank(commenter); + IERC1155(address(mock1155)).setApprovalForAll(address(callerAndCommenter), true); + + vm.expectEmit(true, true, true, true); + emit IComments.Commented({ + commentId: expectedCommentId, + commentIdentifier: expectedCommentIdentifier, + replyToId: bytes32(0), + replyTo: emptyCommentIdentifier, + sparksQuantity: 1, + text: comment, + timestamp: block.timestamp, + referrer: address(0) + }); + vm.expectEmit(true, true, true, true); + emit ICallerAndCommenter.SwappedOnSecondaryAndCommented( + expectedCommentId, + expectedCommentIdentifier, + quantityToSwap, + comment, + ICallerAndCommenter.SwapDirection.SELL + ); + + // make sure the mock secondary swap received the 1155s + // using the expected call + vm.expectCall( + address(mockSecondarySwap), + 0, + abi.encodeWithSelector(IERC1155Receiver.onERC1155Received.selector, address(callerAndCommenter), commenter, tokenId1, quantityToSwap, expectedData) + ); + + vm.deal(commenter, SPARKS_VALUE); + vm.prank(commenter); + callerAndCommenter.sellOnSecondaryAndComment{value: SPARKS_VALUE}( + commenter, + quantityToSwap, + address(mock1155), + tokenId1, + recipient, + minEthToAcquire, + sqrtPriceLimitX96, + comment + ); + + // make sure the mock secondary swap received the 1155s + assertEq(IERC1155(address(mock1155)).balanceOf(address(mockSecondarySwap), tokenId1), quantityToSwap); + } + + function testFork_sellOnSecondaryAndComment() public { + // upgrade the forked caller and commenter to the new implementation + upgradeForkCallerAndCommenterToNewImplementation(); + // this is a known zora test collection where we can secondary swap + address test1155Address = 0xE79585bF83BbBfAE0fB80222b0a72F2c1D040612; + uint256 testTokenId = 1; + + uint256 quantityToBuy = 5; + + uint256 maxEthToSpend = 237222215770897 * quantityToBuy; + uint256 valueToSpend = maxEthToSpend; + + // first we need to buy the 1155s on secondary + vm.deal(commenter, valueToSpend); + vm.prank(commenter); + callerAndCommenter.buyOnSecondaryAndComment{value: valueToSpend}({ + commenter: commenter, + quantity: quantityToBuy, + collection: test1155Address, + tokenId: testTokenId, + excessRefundRecipient: payable(commenter), + maxEthToSpend: maxEthToSpend, + sqrtPriceLimitX96: 0, + comment: "test comment when buying" + }); + + // now we can sell the 1155s on secondary + + uint256 quantityToSell = 3; + string memory sellingComment = "test comment when selling"; + + vm.prank(commenter); + IERC1155(address(test1155Address)).setApprovalForAll(address(callerAndCommenter), true); + + // perform the sell + vm.deal(commenter, SPARKS_VALUE); + vm.prank(commenter); + callerAndCommenter.sellOnSecondaryAndComment{value: SPARKS_VALUE}({ + commenter: commenter, + quantity: quantityToSell, + collection: test1155Address, + tokenId: testTokenId, + recipient: payable(commenter), + minEthToAcquire: 0, + sqrtPriceLimitX96: 0, + comment: sellingComment + }); + } + + function testSellOnSecondaryAndComment_CommenterMismatch() public { + vm.expectRevert(abi.encodeWithSelector(ICallerAndCommenter.CommenterMismatch.selector, commenter, makeAddr("other"))); + vm.prank(commenter); + callerAndCommenter.sellOnSecondaryAndComment(makeAddr("other"), 1, address(mock1155), tokenId1, payable(address(0)), 1 ether, 1000, "test comment"); + } + + function testSellOnSecondaryAndComment_SaleNotSet() public { + uint256 quantityToSwap = 5; + mock1155.mint(commenter, tokenId1, quantityToSwap, ""); + vm.prank(commenter); + IERC1155(address(mock1155)).setApprovalForAll(address(callerAndCommenter), true); + + vm.expectRevert(ISecondarySwap.SaleNotSet.selector); + vm.prank(commenter); + vm.deal(commenter, SPARKS_VALUE); + callerAndCommenter.sellOnSecondaryAndComment{value: SPARKS_VALUE}( + commenter, + 1, + address(mock1155), + tokenId1, + payable(address(0)), + 1 ether, + 1000, + "test comment" + ); + } + + function testSellOnSecondaryAndComment_RevertsWhenNotOneSparkAndACommentIsSent(uint16 sparksQuantity, bool commentIsSent) public { + uint256 valueToSend = sparksQuantity * SPARKS_VALUE; + // setup the sale so that we have a link between the erc20z and the 1155 + mockMinter.setSale(address(mock1155), tokenId1); + + uint256 quantityToSwap = 5; + mock1155.mint(commenter, tokenId1, quantityToSwap, ""); + + address recipient = makeAddr("recipient"); + uint256 minEthToAcquire = 1 ether; + uint160 sqrtPriceLimitX96 = 1000; + string memory comment = commentIsSent ? "test comment" : ""; + + // commenter needs to approve the caller to transfer the 1155s + // since it is to transfer to the secondary swap + vm.prank(commenter); + IERC1155(address(mock1155)).setApprovalForAll(address(callerAndCommenter), true); + + // if we are sending a comment, we should be required to send one spark + if (commentIsSent) { + if (sparksQuantity != 1) { + vm.expectRevert(abi.encodeWithSelector(ICallerAndCommenter.WrongValueSent.selector, SPARKS_VALUE, valueToSend)); + } + } else { + // if we are not sending a comment, we should not send any ETH + if (valueToSend != 0) { + vm.expectRevert(abi.encodeWithSelector(ICallerAndCommenter.WrongValueSent.selector, 0, valueToSend)); + } + } + + vm.deal(commenter, valueToSend); + vm.prank(commenter); + callerAndCommenter.sellOnSecondaryAndComment{value: valueToSend}( + commenter, + quantityToSwap, + address(mock1155), + tokenId1, + payable(recipient), + minEthToAcquire, + sqrtPriceLimitX96, + comment + ); + } + + function _createPermitBuy( + address _commenter, + uint256 _quantity, + address _collection, + uint256 _tokenId, + uint256 _maxEthToSpend, + uint160 _sqrtPriceLimitX96, + string memory _comment, + uint256 _deadline + ) internal view returns (ICallerAndCommenter.PermitBuyOnSecondaryAndComment memory) { + return + ICallerAndCommenter.PermitBuyOnSecondaryAndComment({ + commenter: _commenter, + quantity: _quantity, + collection: _collection, + tokenId: _tokenId, + maxEthToSpend: _maxEthToSpend, + sqrtPriceLimitX96: _sqrtPriceLimitX96, + comment: _comment, + deadline: _deadline, + nonce: bytes32(0), + sourceChainId: uint32(block.chainid), + destinationChainId: uint32(block.chainid) + }); + } + + function _signPermit(ICallerAndCommenter.PermitBuyOnSecondaryAndComment memory _permit, uint256 _privateKey) internal view returns (bytes memory) { + bytes32 digest = callerAndCommenter.hashPermitBuyOnSecondaryAndComment(_permit); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(_privateKey, digest); + return abi.encodePacked(r, s, v); + } +} diff --git a/packages/comments/test/Comments.t.sol b/packages/comments/test/Comments.t.sol new file mode 100644 index 000000000..21982e969 --- /dev/null +++ b/packages/comments/test/Comments.t.sol @@ -0,0 +1,619 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; + +import {IComments} from "../src/interfaces/IComments.sol"; +import {Mock1155} from "./mocks/Mock1155.sol"; +import {CommentsTestBase} from "./CommentsTestBase.sol"; +import {Mock1155NoCreatorRewardRecipient} from "./mocks/Mock1155NoCreatorRewardRecipient.sol"; +import {Mock1155NoOwner} from "./mocks/Mock1155NoOwner.sol"; +import {CommentsImpl} from "../src/CommentsImpl.sol"; +import {Comments} from "../src/proxy/Comments.sol"; + +contract CommentsTest is CommentsTestBase { + uint256 public constant ZORA_REWARD_PCT = 10; + uint256 public constant REFERRER_REWARD_PCT = 20; + uint256 internal constant BPS_TO_PERCENT_2_DECIMAL_PERCISION = 100; + + function _setupCommenterWithTokenAndSparks(address commenter, uint256 sparksQuantity) internal { + vm.startPrank(commenter); + mock1155.mint(commenter, tokenId1, 1, ""); + vm.stopPrank(); + vm.deal(commenter, sparksQuantity * SPARKS_VALUE); + } + + function _createCommentIdentifier(address commenter, bytes32 nonce) internal view returns (IComments.CommentIdentifier memory) { + return IComments.CommentIdentifier({commenter: commenter, contractAddress: address(mock1155), tokenId: tokenId1, nonce: nonce}); + } + + function testCommentContractName() public view { + assertEq(comments.contractName(), "Zora Comments"); + } + + function testCommentWhenCollectorHasTokenShouldEmitCommented() public { + vm.startPrank(collectorWithToken); + mock1155.mint(collectorWithToken, tokenId1, 1, ""); + vm.stopPrank(); + + vm.deal(collectorWithToken, SPARKS_VALUE); + + address contractAddress = address(mock1155); + uint256 tokenId = tokenId1; + address commenter = collectorWithToken; + + IComments.CommentIdentifier memory expectedCommentIdentifier = _expectedCommentIdentifier(contractAddress, tokenId, commenter); + + // blank replyTo + IComments.CommentIdentifier memory replyTo; + + vm.expectEmit(true, true, true, true); + emit IComments.Commented( + comments.hashCommentIdentifier(expectedCommentIdentifier), + expectedCommentIdentifier, + 0, + replyTo, + 1, + "test comment", + block.timestamp, + address(0) + ); + vm.prank(collectorWithToken); + comments.comment{value: SPARKS_VALUE}(collectorWithToken, contractAddress, tokenId, "test comment", replyTo, address(0), address(0)); + + uint256 zoraReward = (SPARKS_VALUE * (ZORA_REWARD_PCT + REFERRER_REWARD_PCT)) / BPS_TO_PERCENT_2_DECIMAL_PERCISION; + vm.assertEq(protocolRewards.balanceOf(collectorWithToken), 0); + vm.assertEq(protocolRewards.balanceOf(zoraRecipient), zoraReward); + vm.assertEq(protocolRewards.balanceOf(tokenAdmin), SPARKS_VALUE - zoraReward); + } + + function testCommentBackfillBatchAddCommentShouldEmitCommented() public { + IComments.CommentIdentifier[] memory commentIdentifiers = new IComments.CommentIdentifier[](2); + + commentIdentifiers[0] = IComments.CommentIdentifier({ + commenter: makeAddr("commenter1"), + contractAddress: address(mock1155), + tokenId: tokenId1, + nonce: 0 + }); + + commentIdentifiers[1] = IComments.CommentIdentifier({ + commenter: makeAddr("commenter2"), + contractAddress: address(mock1155), + tokenId: tokenId2, + nonce: bytes32("1") + }); + + string[] memory texts = new string[](2); + texts[0] = "test comment 1"; + texts[1] = "test comment 2"; + + uint256[] memory timestamps = new uint256[](2); + + timestamps[0] = block.timestamp; + timestamps[1] = block.timestamp + 100; + + bytes32[] memory originalTransactionHashes = new bytes32[](2); + originalTransactionHashes[0] = bytes32("1"); + originalTransactionHashes[1] = bytes32("2"); + + vm.expectEmit(true, true, true, true); + // verify first comment is emitted + emit IComments.BackfilledComment({ + commentId: comments.hashCommentIdentifier(commentIdentifiers[0]), + commentIdentifier: commentIdentifiers[0], + text: texts[0], + timestamp: timestamps[0], + originalTransactionId: originalTransactionHashes[0] + }); + vm.expectEmit(true, true, true, true); + // verify second comment is emitted + emit IComments.BackfilledComment({ + commentId: comments.hashCommentIdentifier(commentIdentifiers[1]), + commentIdentifier: commentIdentifiers[1], + text: texts[1], + timestamp: timestamps[1], + originalTransactionId: originalTransactionHashes[1] + }); + + vm.prank(commentsBackfiller); + comments.backfillBatchAddComment(commentIdentifiers, texts, timestamps, originalTransactionHashes); + } + + function testCommentBackfillBatchAddCommentShouldRevertIfDuplicate() public { + IComments.CommentIdentifier[] memory commentIdentifiers = new IComments.CommentIdentifier[](2); + + commentIdentifiers[0] = IComments.CommentIdentifier({ + commenter: makeAddr("commenter1"), + contractAddress: address(mock1155), + tokenId: tokenId1, + nonce: 0 + }); + + commentIdentifiers[1] = IComments.CommentIdentifier({ + commenter: makeAddr("commenter2"), + contractAddress: address(mock1155), + tokenId: tokenId2, + nonce: keccak256("1") + }); + + string[] memory texts = new string[](2); + texts[0] = "test comment 1"; + texts[1] = "test comment 2"; + + uint256[] memory timestamps = new uint256[](2); + + timestamps[0] = block.timestamp; + timestamps[1] = block.timestamp + 100; + + bytes32[] memory originalTransactionHashes = new bytes32[](2); + originalTransactionHashes[0] = bytes32("1"); + originalTransactionHashes[1] = bytes32("2"); + + vm.prank(commentsBackfiller); + comments.backfillBatchAddComment(commentIdentifiers, texts, timestamps, originalTransactionHashes); + + // ensure that when backfilling a duplicate, it reverts + vm.expectRevert(abi.encodeWithSelector(IComments.DuplicateComment.selector, comments.hashCommentIdentifier(commentIdentifiers[0]))); + vm.prank(commentsBackfiller); + comments.backfillBatchAddComment(commentIdentifiers, texts, timestamps, originalTransactionHashes); + } + + function testCommentBackfillBatchAddCommentShouldRevertIfArrayLengthMismatch() public { + IComments.CommentIdentifier[] memory commentIdentifiers = new IComments.CommentIdentifier[](2); + commentIdentifiers[1] = commentIdentifiers[0]; + + string[] memory texts = new string[](2); + + uint256[] memory timestamps = new uint256[](1); // Mismatched length + + bytes32[] memory originalTransactionHashes = new bytes32[](2); + + vm.expectRevert(IComments.ArrayLengthMismatch.selector); + vm.prank(commentsBackfiller); + comments.backfillBatchAddComment(commentIdentifiers, texts, timestamps, originalTransactionHashes); + } + + function testCommentSparkCommentWithZeroSparks() public { + IComments.CommentIdentifier memory commentIdentifier = IComments.CommentIdentifier({ + commenter: collectorWithToken, + contractAddress: address(mock1155), + tokenId: tokenId1, + nonce: bytes32(0) + }); + vm.expectRevert(abi.encodeWithSignature("MustSendAtLeastOneSpark()")); + comments.sparkComment(commentIdentifier, 0, address(0)); + } + + function testCommentSparkCommentWithInvalidAmount() public { + IComments.CommentIdentifier memory commentIdentifier = IComments.CommentIdentifier({ + commenter: collectorWithToken, + contractAddress: address(mock1155), + tokenId: tokenId1, + nonce: bytes32(0) + }); + vm.expectRevert(abi.encodeWithSelector(IComments.IncorrectETHAmountForSparks.selector, 0, SPARKS_VALUE)); + comments.sparkComment{value: 0}(commentIdentifier, 1, address(0)); + } + + function testCommentSparkCommentOwnComment() public { + // comment + IComments.CommentIdentifier memory replyTo; + IComments.CommentIdentifier memory commentIdentifier = _mockComment(collectorWithToken, replyTo); + + // spark own comment + vm.deal(collectorWithToken, SPARKS_VALUE); + + vm.expectRevert(abi.encodeWithSignature("CannotSparkOwnComment()")); + vm.prank(collectorWithToken); + comments.sparkComment{value: SPARKS_VALUE}(commentIdentifier, 1, address(0)); + } + + function testCommentSparkCommentDoesNotExist() public { + IComments.CommentIdentifier memory commentIdentifier = IComments.CommentIdentifier({ + commenter: collectorWithToken, + contractAddress: address(mock1155), + tokenId: tokenId1, + nonce: keccak256("123456") + }); + vm.expectRevert(abi.encodeWithSignature("CommentDoesntExist()")); + comments.sparkComment{value: SPARKS_VALUE}(commentIdentifier, 1, address(0)); + } + + function testCommentSparkCommentValid(uint256 sparksQuantity) public { + vm.assume(sparksQuantity > 0 && sparksQuantity < 1_000_000_000_000_000); + + // comment + IComments.CommentIdentifier memory replyTo; + IComments.CommentIdentifier memory commentIdentifier = _mockComment(collectorWithToken, replyTo); + + // mint + address commenter2 = makeAddr("commenter2"); + + uint256 zoraRecipientBalanceBeforeSpark = protocolRewards.balanceOf(zoraRecipient); + + // spark comment + vm.deal(commenter2, sparksQuantity * SPARKS_VALUE); + + vm.expectEmit(true, true, true, true); + emit IComments.SparkedComment( + comments.hashCommentIdentifier(commentIdentifier), + commentIdentifier, + sparksQuantity, + commenter2, + block.timestamp, + address(0) + ); + vm.prank(commenter2); + comments.sparkComment{value: sparksQuantity * SPARKS_VALUE}(commentIdentifier, sparksQuantity, address(0)); + + uint256 zoraReward = (sparksQuantity * SPARKS_VALUE * (ZORA_REWARD_PCT + REFERRER_REWARD_PCT)) / BPS_TO_PERCENT_2_DECIMAL_PERCISION; + vm.assertEq(protocolRewards.balanceOf(zoraRecipient) - zoraRecipientBalanceBeforeSpark, zoraReward); + vm.assertEq(protocolRewards.balanceOf(collectorWithToken), (sparksQuantity * SPARKS_VALUE) - zoraReward); + } + + function testCommentSparkCommentValidWithReferrer(uint256 sparksQuantity) public { + vm.assume(sparksQuantity > 0 && sparksQuantity < 1_000_000_000_000_000); + + // comment + IComments.CommentIdentifier memory replyTo; + IComments.CommentIdentifier memory commentIdentifier = _mockComment(collectorWithToken, replyTo); + + // mint + address commenter2 = makeAddr("commenter2"); + + uint256 zoraRecipientBalanceBeforeSpark = protocolRewards.balanceOf(zoraRecipient); + + // spark comment + vm.deal(commenter2, sparksQuantity * SPARKS_VALUE); + + address referrer = makeAddr("referrer"); + + vm.expectEmit(true, true, true, true); + emit IComments.SparkedComment( + comments.hashCommentIdentifier(commentIdentifier), + commentIdentifier, + sparksQuantity, + commenter2, + block.timestamp, + referrer + ); + vm.prank(commenter2); + comments.sparkComment{value: sparksQuantity * SPARKS_VALUE}(commentIdentifier, sparksQuantity, referrer); + + uint256 zoraReward = (sparksQuantity * SPARKS_VALUE * ZORA_REWARD_PCT) / BPS_TO_PERCENT_2_DECIMAL_PERCISION; + uint256 referrerReward = (sparksQuantity * SPARKS_VALUE * REFERRER_REWARD_PCT) / BPS_TO_PERCENT_2_DECIMAL_PERCISION; + vm.assertEq(protocolRewards.balanceOf(zoraRecipient) - zoraRecipientBalanceBeforeSpark, zoraReward); + vm.assertEq(protocolRewards.balanceOf(referrer), referrerReward); + vm.assertEq(protocolRewards.balanceOf(collectorWithToken), (sparksQuantity * SPARKS_VALUE) - zoraReward - referrerReward); + } + + function testCommentHashAndValidateCommentExists() public { + IComments.CommentIdentifier memory commentIdentifier = IComments.CommentIdentifier({ + commenter: collectorWithToken, + contractAddress: address(mock1155), + tokenId: tokenId1, + nonce: keccak256("123456") + }); + vm.expectRevert(abi.encodeWithSignature("CommentDoesntExist()")); + comments.hashAndValidateCommentExists(commentIdentifier); + } + + function postComment( + address commenter, + address contractAddress, + uint256 tokenId, + string memory content, + IComments.CommentIdentifier memory replyTo + ) internal returns (IComments.CommentIdentifier memory) { + vm.prank(commenter); + return comments.comment{value: SPARKS_VALUE}(commenter, contractAddress, tokenId, content, replyTo, address(0), address(0)); + } + + function testHashAndCheckCommentExists() public { + address commenter = collectorWithToken; + address contractAddress = address(mock1155); + uint256 tokenId = tokenId1; + + // Check that the comment doesn't exist initially + (bytes32 commentId, bool exists) = comments.hashAndCheckCommentExists(_expectedCommentIdentifier(contractAddress, tokenId, commenter)); + assertFalse(exists); + + // Setup and post comment + _setupCommenterWithTokenAndSparks(commenter, 1); + IComments.CommentIdentifier memory postedCommentIdentifier = postComment(commenter, contractAddress, tokenId, "test comment", emptyCommentIdentifier); + + // Check that the comment now exists + (bytes32 newCommentId, bool newExists) = comments.hashAndCheckCommentExists(postedCommentIdentifier); + assertTrue(newExists); + assertEq(commentId, newCommentId); + assertEq(comments.hashCommentIdentifier(postedCommentIdentifier), newCommentId); + } + + function testReplyToNonExistentComment() public { + address commenter = makeAddr("commenter"); + _setupCommenterWithTokenAndSparks(commenter, 1); + + IComments.CommentIdentifier memory nonExistentReplyTo = IComments.CommentIdentifier({ + commenter: makeAddr("nonExistentCommenter"), + contractAddress: address(mock1155), + tokenId: tokenId1, + nonce: keccak256("nonExistentNonce") + }); + + vm.expectRevert(IComments.CommentDoesntExist.selector); + postComment(commenter, address(mock1155), tokenId1, "Replying to non-existent comment", nonExistentReplyTo); + } + + function testReplyToCommentThatAddressDoesNotMatch() public { + address originalCommenter = makeAddr("originalCommenter"); + address replier = makeAddr("replier"); + + _setupCommenterWithTokenAndSparks(originalCommenter, 1); + _setupCommenterWithTokenAndSparks(replier, 1); + + IComments.CommentIdentifier memory originalCommentIdentifier = postComment( + originalCommenter, + address(mock1155), + tokenId1, + "Original comment", + emptyCommentIdentifier + ); + + // mismatched address + address mismatchedAddress = makeAddr("xyz"); + + vm.expectRevert( + abi.encodeWithSelector(IComments.CommentAddressOrTokenIdsDoNotMatch.selector, mismatchedAddress, tokenId1, address(mock1155), tokenId1) + ); + vm.prank(replier); + comments.comment{value: SPARKS_VALUE}( + replier, + mismatchedAddress, + tokenId1, + "Reply to original comment", + originalCommentIdentifier, + address(0), + address(0) + ); + + // mismatched tokenId + uint256 mismatchedTokenId = 123; + + vm.expectRevert( + abi.encodeWithSelector(IComments.CommentAddressOrTokenIdsDoNotMatch.selector, address(mock1155), mismatchedTokenId, address(mock1155), tokenId1) + ); + vm.prank(replier); + comments.comment{value: SPARKS_VALUE}( + replier, + address(mock1155), + mismatchedTokenId, + "Reply to original comment", + originalCommentIdentifier, + address(0), + address(0) + ); + } + + function testReplyToExistingComment() public { + address originalCommenter = makeAddr("originalCommenter"); + address replier = makeAddr("replier"); + + _setupCommenterWithTokenAndSparks(originalCommenter, 1); + _setupCommenterWithTokenAndSparks(replier, 1); + + IComments.CommentIdentifier memory originalCommentIdentifier = postComment( + originalCommenter, + address(mock1155), + tokenId1, + "Original comment", + emptyCommentIdentifier + ); + + IComments.CommentIdentifier memory expectedReplyCommentIdentifier = _expectedCommentIdentifier(address(mock1155), tokenId1, replier); + + vm.expectEmit(true, true, true, true); + emit IComments.Commented( + comments.hashCommentIdentifier(expectedReplyCommentIdentifier), + expectedReplyCommentIdentifier, + comments.hashCommentIdentifier(originalCommentIdentifier), + originalCommentIdentifier, + 1, + "Reply to original comment", + block.timestamp, + address(0) + ); + + IComments.CommentIdentifier memory replyCommentIdentifier = postComment( + replier, + address(mock1155), + tokenId1, + "Reply to original comment", + originalCommentIdentifier + ); + + assertEq(payable(address(comments)).balance, 0, "comments contract should have no balance"); + + uint256 commenteeSparks = comments.commentSparksQuantity(originalCommentIdentifier); + assertEq(commenteeSparks, 0, "commentee sparks should be 0"); + + assertEq(protocolRewards.balanceOf(originalCommenter), (SPARKS_VALUE * 70) / 100, "rewards mismatch"); + + (, bool exists) = comments.hashAndCheckCommentExists(replyCommentIdentifier); + assertTrue(exists); + } + + function testCommentMismatchedCommenter() public { + address actualCommenter = makeAddr("actualCommenter"); + address mismatchedCommenter = makeAddr("mismatchedCommenter"); + + _setupCommenterWithTokenAndSparks(actualCommenter, 1); + + vm.expectRevert(abi.encodeWithSelector(IComments.CommenterMismatch.selector, mismatchedCommenter, actualCommenter)); + vm.prank(actualCommenter); + comments.comment{value: SPARKS_VALUE}( + mismatchedCommenter, + address(mock1155), + tokenId1, + "Mismatched commenter", + emptyCommentIdentifier, + address(0), + address(0) + ); + } + + function testRevertOnEmptyComment() public { + address commenter = makeAddr("commenter"); + _setupCommenterWithTokenAndSparks(commenter, 1); + + vm.expectRevert(IComments.EmptyComment.selector); + postComment(commenter, address(mock1155), tokenId1, "", emptyCommentIdentifier); + } + + function testRevertOnNotTokenHolderOrAdmin() public { + address nonHolder = makeAddr("nonHolder"); + + // We don't set up the commenter with a token, so they're neither a holder nor an admin + vm.deal(nonHolder, SPARKS_VALUE); + + vm.expectRevert(IComments.NotTokenHolderOrAdmin.selector); + postComment(nonHolder, address(mock1155), tokenId1, "Attempting to comment without holding token", emptyCommentIdentifier); + } + + function testCommentRevertsWhenSendTooMuchValue() public { + address tokenHolder = makeAddr("tokenHolder"); + + _setupCommenterWithTokenAndSparks(tokenHolder, 1); + + vm.deal(tokenHolder, 1 ether); + vm.prank(tokenHolder); + vm.expectRevert(abi.encodeWithSelector(IComments.IncorrectETHAmountForSparks.selector, 1 ether, SPARKS_VALUE)); + comments.comment{value: 1 ether}(tokenHolder, address(mock1155), tokenId1, "test", emptyCommentIdentifier, address(0), address(0)); + } + + function testCommentRevertsWhenSendTooLittleValue() public { + address tokenHolder = makeAddr("tokenHolder"); + + _setupCommenterWithTokenAndSparks(tokenHolder, 1); + + vm.prank(tokenHolder); + vm.expectRevert(abi.encodeWithSelector(IComments.MustSendAtLeastOneSpark.selector)); + comments.comment(tokenHolder, address(mock1155), tokenId1, "test", emptyCommentIdentifier, address(0), address(0)); + } + + function testCommentRevertsWhenSendExactValue() public { + address tokenHolder = makeAddr("tokenHolder"); + + _setupCommenterWithTokenAndSparks(tokenHolder, 1); + + vm.prank(tokenHolder); + comments.comment{value: SPARKS_VALUE}(tokenHolder, address(mock1155), tokenId1, "test", emptyCommentIdentifier, address(0), address(0)); + } + + function testCommentWithMock1155NoCreatorRewardRecipient() public { + Mock1155NoCreatorRewardRecipient mock1155NoCreatorRewardRecipient = new Mock1155NoCreatorRewardRecipient(); + mock1155NoCreatorRewardRecipient.createToken(tokenId1, tokenAdmin); + + vm.startPrank(collectorWithToken); + mock1155NoCreatorRewardRecipient.mint(collectorWithToken, tokenId1, 1, ""); + vm.stopPrank(); + + uint256 sparksQuantity = 1; + + vm.deal(collectorWithToken, sparksQuantity * SPARKS_VALUE); + + address contractAddress = address(mock1155NoCreatorRewardRecipient); + uint256 tokenId = tokenId1; + + // blank replyTo + IComments.CommentIdentifier memory replyTo; + + // funds recipient is 0x000... + vm.prank(collectorWithToken); + vm.expectRevert(abi.encodeWithSelector(IComments.NoFundsRecipient.selector)); + comments.comment{value: sparksQuantity * SPARKS_VALUE}(collectorWithToken, contractAddress, tokenId, "test comment", replyTo, address(0), address(0)); + + // with funds recipient set + address newFundsRecipient = makeAddr("newFundsRecipient"); + mock1155NoCreatorRewardRecipient.setFundsRecipient(payable(newFundsRecipient)); + vm.deal(collectorWithToken, sparksQuantity * SPARKS_VALUE); + + vm.prank(collectorWithToken); + comments.comment{value: sparksQuantity * SPARKS_VALUE}( + collectorWithToken, + contractAddress, + tokenId, + "test comment with recipient", + replyTo, + address(0), + address(0) + ); + + uint256 zoraReward = (sparksQuantity * SPARKS_VALUE * (ZORA_REWARD_PCT + REFERRER_REWARD_PCT)) / BPS_TO_PERCENT_2_DECIMAL_PERCISION; + uint256 recipientReward = (sparksQuantity * SPARKS_VALUE) - zoraReward; + vm.assertEq(protocolRewards.balanceOf(newFundsRecipient), recipientReward); + } + + function testCommentWithMock1155NoOwner() public { + Mock1155NoOwner mock1155NoOwner = new Mock1155NoOwner(); + mock1155NoOwner.createToken(tokenId1, tokenAdmin); + + vm.startPrank(collectorWithToken); + mock1155NoOwner.mint(collectorWithToken, tokenId1, 1, ""); + vm.stopPrank(); + + uint256 sparksQuantity = 1; + vm.deal(collectorWithToken, sparksQuantity * SPARKS_VALUE); + + address contractAddress = address(mock1155NoOwner); + uint256 tokenId = tokenId1; + + IComments.CommentIdentifier memory replyTo; + + vm.prank(collectorWithToken); + vm.expectRevert(abi.encodeWithSelector(IComments.NoFundsRecipient.selector)); + comments.comment{value: sparksQuantity * SPARKS_VALUE}(collectorWithToken, contractAddress, tokenId, "test comment", replyTo, address(0), address(0)); + } + + function testImplementation() public view { + assertEq(comments.implementation(), address(commentsImpl)); + } + + function testCommentsConstructorAddressZero() public { + vm.expectRevert(abi.encodeWithSelector(IComments.AddressZero.selector)); + new CommentsImpl(SPARKS_VALUE, address(0), zoraRecipient); + + vm.expectRevert(abi.encodeWithSelector(IComments.AddressZero.selector)); + new CommentsImpl(SPARKS_VALUE, address(protocolRewards), address(0)); + } + + function testCommentsInitializeAddressZero() public { + CommentsImpl implTest = new CommentsImpl(SPARKS_VALUE, address(protocolRewards), zoraRecipient); + address[] memory delegateCommenters = new address[](0); + + CommentsImpl commentsProxyTest = CommentsImpl(payable(address(new Comments(address(implTest))))); + vm.expectRevert(abi.encodeWithSelector(IComments.AddressZero.selector)); + commentsProxyTest.initialize({defaultAdmin: address(0), backfiller: commentsBackfiller, delegateCommenters: delegateCommenters}); + + vm.expectRevert(abi.encodeWithSelector(IComments.AddressZero.selector)); + commentsProxyTest.initialize({defaultAdmin: commentsAdmin, backfiller: address(0), delegateCommenters: new address[](0)}); + } + + function testGrantRoleWithBackfillRole() public { + address newBackfiller = makeAddr("newBackfiller"); + bytes32 BACKFILLER_ROLE = keccak256("BACKFILLER_ROLE"); + vm.prank(commentsAdmin); + comments.grantRole(BACKFILLER_ROLE, newBackfiller); + vm.assertEq(comments.hasRole(BACKFILLER_ROLE, newBackfiller), true); + + vm.prank(commentsAdmin); + comments.revokeRole(BACKFILLER_ROLE, newBackfiller); + vm.assertEq(comments.hasRole(BACKFILLER_ROLE, newBackfiller), false); + + address notAdmin = makeAddr("notAdmin"); + bytes32 no_role; + vm.prank(notAdmin); + vm.expectRevert(abi.encodeWithSignature("AccessControlUnauthorizedAccount(address,bytes32)", notAdmin, no_role)); + comments.grantRole(BACKFILLER_ROLE, newBackfiller); + } +} diff --git a/packages/comments/test/CommentsTestBase.sol b/packages/comments/test/CommentsTestBase.sol new file mode 100644 index 000000000..500621170 --- /dev/null +++ b/packages/comments/test/CommentsTestBase.sol @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; + +import {CommentsImpl} from "../src/CommentsImpl.sol"; +import {Comments} from "../src/proxy/Comments.sol"; +import {IComments} from "../src/interfaces/IComments.sol"; +import {Mock1155} from "./mocks/Mock1155.sol"; +import {ProtocolRewards} from "./mocks/ProtocolRewards.sol"; + +contract CommentsTestBase is Test { + CommentsImpl internal comments; + CommentsImpl internal commentsImpl; + Mock1155 internal mock1155; + + uint256 internal constant SPARKS_VALUE = 0.000001 ether; + + IComments.CommentIdentifier internal emptyCommentIdentifier; + ProtocolRewards internal protocolRewards; + + address internal commentsAdmin = makeAddr("commentsAdmin"); + address internal commentsBackfiller = makeAddr("commentsBackfiller"); + address internal zoraRecipient = makeAddr("zoraRecipient"); + address internal tokenAdmin; + uint256 internal tokenAdminPrivateKey; + address internal collectorWithToken; + uint256 internal collectorWithTokenPrivateKey; + address internal collectorWithoutToken = makeAddr("collectorWithoutToken"); + address internal sparker; + uint256 internal sparkerPrivateKey; + + uint256 internal tokenId1 = 1; + uint256 internal tokenId2 = 2; + + function setUp() public { + protocolRewards = new ProtocolRewards(); + commentsImpl = new CommentsImpl(SPARKS_VALUE, address(protocolRewards), zoraRecipient); + + // initialze empty delegateCommenters array + address[] memory delegateCommenters = new address[](0); + + // intialize proxy + comments = CommentsImpl(payable(address(new Comments(address(commentsImpl))))); + comments.initialize({defaultAdmin: commentsAdmin, backfiller: commentsBackfiller, delegateCommenters: delegateCommenters}); + + mock1155 = new Mock1155(); + (tokenAdmin, tokenAdminPrivateKey) = makeAddrAndKey("tokenAdmin"); + + mock1155.createToken(tokenId1, tokenAdmin); + mock1155.createToken(tokenId2, tokenAdmin); + + (collectorWithToken, collectorWithTokenPrivateKey) = makeAddrAndKey("collectorWithToken"); + (sparker, sparkerPrivateKey) = makeAddrAndKey("sparker"); + } + + function _expectedCommentIdentifier( + address contractAddress, + uint256 tokenId, + address commenter + ) internal view returns (IComments.CommentIdentifier memory) { + return IComments.CommentIdentifier({commenter: commenter, contractAddress: contractAddress, tokenId: tokenId, nonce: comments.nextNonce()}); + } + + function _mockComment( + address commenter, + IComments.CommentIdentifier memory replyTo + ) internal returns (IComments.CommentIdentifier memory commentIdentifier) { + vm.startPrank(commenter); + mock1155.mint(commenter, tokenId1, 1, ""); + vm.stopPrank(); + + vm.deal(commenter, SPARKS_VALUE); + + vm.prank(commenter); + commentIdentifier = comments.comment{value: SPARKS_VALUE}(commenter, address(mock1155), tokenId1, "comment", replyTo, address(0), address(0)); + } +} diff --git a/packages/comments/test/Comments_delegateComment.t.sol b/packages/comments/test/Comments_delegateComment.t.sol new file mode 100644 index 000000000..153323a4a --- /dev/null +++ b/packages/comments/test/Comments_delegateComment.t.sol @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; + +import {CommentsImpl} from "../src/CommentsImpl.sol"; +import {Comments} from "../src/proxy/Comments.sol"; +import {IComments} from "../src/interfaces/IComments.sol"; +import {Mock1155} from "./mocks/Mock1155.sol"; +import {MockDelegateCommenter} from "./mocks/MockDelegateCommenter.sol"; + +contract Comments_mintAndCommentTest is Test { + Mock1155 mock1155; + CommentsImpl comments; + + uint256 constant SPARKS_VALUE = 0.000001 ether; + + address zoraRecipient = makeAddr("zoraRecipient"); + address commentsAdmin = makeAddr("commentsAdmin"); + address commenter = makeAddr("commenter"); + address tokenAdmin = makeAddr("tokenAdmin"); + address backfiller = makeAddr("backfiller"); + address referrer = makeAddr("referrer"); + + uint256 internal constant ZORA_REWARD_PCT = 10; + uint256 internal constant REFERRER_REWARD_PCT = 20; + uint256 internal constant BPS_TO_PERCENT_2_DECIMAL_PERCISION = 100; + + uint256 tokenId1 = 1; + + address constant protocolRewards = 0x7777777F279eba3d3Ad8F4E708545291A6fDBA8B; + MockDelegateCommenter mockDelegateCommenter; + + function setUp() public { + vm.createSelectFork("zora_sepolia", 14562731); + + CommentsImpl commentsImpl = new CommentsImpl(SPARKS_VALUE, protocolRewards, zoraRecipient); + + comments = CommentsImpl(payable(address(new Comments(address(commentsImpl))))); + + mockDelegateCommenter = new MockDelegateCommenter(address(comments)); + + address[] memory delegateCommenters = new address[](1); + delegateCommenters[0] = address(mockDelegateCommenter); + comments.initialize({defaultAdmin: commentsAdmin, backfiller: backfiller, delegateCommenters: delegateCommenters}); + + mock1155 = new Mock1155(); + + mock1155.createToken(tokenId1, tokenAdmin); + } + + function _expectedCommentIdentifier( + address _commenter, + address contractAddress, + uint256 tokenId + ) internal view returns (IComments.CommentIdentifier memory) { + return IComments.CommentIdentifier({commenter: _commenter, contractAddress: contractAddress, tokenId: tokenId, nonce: comments.nextNonce()}); + } + + function testCanDelegateCommentWithSparks() public { + uint256 quantityToMint = 1; + uint256 mintFee = 0.000111 ether; + + address contractAddress = address(mock1155); + uint256 tokenId = tokenId1; + + IComments.CommentIdentifier memory emptyReplyTo; + + IComments.CommentIdentifier memory expectedCommentIdentifier = _expectedCommentIdentifier(commenter, contractAddress, tokenId); + + bytes32 expectedCommentId = comments.hashCommentIdentifier(expectedCommentIdentifier); + bytes32 expectedReplyToId = bytes32(0); + + vm.deal(commenter, mintFee * quantityToMint + SPARKS_VALUE); + vm.expectEmit(true, true, true, true); + emit IComments.Commented( + expectedCommentId, + _expectedCommentIdentifier(commenter, contractAddress, tokenId), + expectedReplyToId, + emptyReplyTo, + 1, + "test", + block.timestamp, + referrer + ); + vm.prank(commenter); + mockDelegateCommenter.mintAndCommentWithSpark{value: SPARKS_VALUE + mintFee * quantityToMint}({ + quantity: quantityToMint, + collection: address(mock1155), + tokenId: tokenId1, + comment: "test", + referrer: referrer, + sparksQuantity: 1 + }); + + // validate that the protocol creator received rewards + uint256 zoraReward = (SPARKS_VALUE * (ZORA_REWARD_PCT)) / BPS_TO_PERCENT_2_DECIMAL_PERCISION; + uint256 referrerReward = (SPARKS_VALUE * (REFERRER_REWARD_PCT)) / BPS_TO_PERCENT_2_DECIMAL_PERCISION; + vm.assertEq(comments.protocolRewards().balanceOf(zoraRecipient), zoraReward); + vm.assertEq(comments.protocolRewards().balanceOf(referrer), referrerReward); + vm.assertEq(comments.protocolRewards().balanceOf(tokenAdmin), SPARKS_VALUE - zoraReward - referrerReward); + } + + function testDelegateCommentRevertsWhenMoreThanOneSpark() public { + uint256 quantityToMint = 1; + uint256 mintFee = 0.000111 ether; + + uint256 sparksQuantity = 2; + vm.deal(commenter, mintFee * quantityToMint + SPARKS_VALUE * sparksQuantity); + + vm.prank(commenter); + vm.expectRevert(abi.encodeWithSelector(IComments.IncorrectETHAmountForSparks.selector, SPARKS_VALUE * sparksQuantity, SPARKS_VALUE)); + mockDelegateCommenter.mintAndCommentWithSpark{value: SPARKS_VALUE * sparksQuantity + mintFee * quantityToMint}({ + quantity: quantityToMint, + collection: address(mock1155), + tokenId: tokenId1, + comment: "test", + referrer: referrer, + sparksQuantity: sparksQuantity + }); + } + + function test_delegateComment_revertsWhenNotOwnerOrCreator() public { + address notOwner = makeAddr("notOwner"); + vm.prank(notOwner); + vm.expectRevert(IComments.NotTokenHolderOrAdmin.selector); + mockDelegateCommenter.forwardComment(address(mock1155), tokenId1, "test"); + } +} diff --git a/packages/comments/test/Comments_permit.t.sol b/packages/comments/test/Comments_permit.t.sol new file mode 100644 index 000000000..a1b9d4a02 --- /dev/null +++ b/packages/comments/test/Comments_permit.t.sol @@ -0,0 +1,484 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; + +import {CommentsImpl} from "../src/CommentsImpl.sol"; +import {Comments} from "../src/proxy/Comments.sol"; +import {IComments} from "../src/interfaces/IComments.sol"; +import {Mock1155} from "./mocks/Mock1155.sol"; +import {IProtocolRewards} from "@zoralabs/protocol-rewards/src/interfaces/IProtocolRewards.sol"; +import {ProtocolRewards} from "./mocks/ProtocolRewards.sol"; +import {CommentsTestBase} from "./CommentsTestBase.sol"; +import {UnorderedNoncesUpgradeable} from "@zoralabs/shared-contracts/utils/UnorderedNoncesUpgradeable.sol"; +import {MockMultiOwnable} from "./Comments_smartWallet.t.sol"; + +contract CommentsPermitTest is CommentsTestBase { + function testPermitComment() public { + IComments.PermitComment memory permitComment = _createPermitComment(collectorWithToken, "test comment"); + bytes memory signature = _signPermitComment(permitComment, collectorWithTokenPrivateKey); + + IComments.CommentIdentifier memory expectedCommentIdentifier = _expectedCommentIdentifier( + permitComment.contractAddress, + permitComment.tokenId, + permitComment.commenter + ); + + _setupTokenAndSparks(permitComment); + + // any account can execute the permit comment on behalf of the collectorWithToken, but they must have enough eth to do so. + address executor = makeAddr("executor"); + vm.deal(executor, SPARKS_VALUE); + + vm.expectEmit(true, true, true, true); + emit IComments.Commented( + comments.hashCommentIdentifier(expectedCommentIdentifier), + expectedCommentIdentifier, + bytes32(0), + permitComment.replyTo, + 1, + permitComment.text, + block.timestamp, + permitComment.referrer + ); + + _executePermitComment(executor, permitComment, signature); + + _assertCommentExists(expectedCommentIdentifier); + } + + function testPermitComment_NonceUsedTwice() public { + IComments.PermitComment memory permitComment = _createPermitComment(collectorWithToken, "test comment"); + bytes memory signature = _signPermitComment(permitComment, collectorWithTokenPrivateKey); + + _setupTokenAndSparks(permitComment); + + // First comment should succeed + vm.deal(collectorWithToken, 10 ether); + _executePermitComment(collectorWithToken, permitComment, signature); + + // Second comment with same nonce should fail + vm.expectRevert(abi.encodeWithSelector(UnorderedNoncesUpgradeable.InvalidAccountNonce.selector, collectorWithToken, permitComment.nonce)); + _executePermitComment(collectorWithToken, permitComment, signature); + } + + function testPermitComment_DeadlineExpired() public { + IComments.PermitComment memory permitComment = _createPermitComment(collectorWithToken, "test comment"); + bytes memory signature = _signPermitComment(permitComment, collectorWithTokenPrivateKey); + + _setupTokenAndSparks(permitComment); + + // Warp time to after the deadline + vm.warp(permitComment.deadline + 1); + + address executor = makeAddr("executor"); + vm.deal(executor, SPARKS_VALUE); + vm.expectRevert(abi.encodeWithSelector(IComments.ERC2612ExpiredSignature.selector, permitComment.deadline)); + _executePermitComment(executor, permitComment, signature); + } + + function testPermitComment_CommenterDoesntMatchSigner() public { + address wrongSigner; + uint256 wrongSignerPrivateKey; + (wrongSigner, wrongSignerPrivateKey) = makeAddrAndKey("wrongSigner"); + + IComments.PermitComment memory permitComment = _createPermitComment(collectorWithToken, "test comment"); + bytes memory wrongSignature = _signPermitComment(permitComment, wrongSignerPrivateKey); + + _setupTokenAndSparks(permitComment); + + vm.expectRevert(IComments.InvalidSignature.selector); + _executePermitComment(collectorWithToken, permitComment, wrongSignature); + } + + function testPermitComment_ZeroSparks_Collector() public { + IComments.PermitComment memory permitComment = _createPermitComment(collectorWithToken, "test comment"); + bytes memory signature = _signPermitComment(permitComment, collectorWithTokenPrivateKey); + + _setupTokenAndSparks(permitComment); + + vm.expectRevert(IComments.MustSendAtLeastOneSpark.selector); + vm.prank(collectorWithToken); + comments.permitComment{value: 0}(permitComment, signature); + } + + function testPermitComment_ZeroSparks_Creator() public { + IComments.PermitComment memory permitComment = _createPermitComment(tokenAdmin, "test comment"); + bytes memory signature = _signPermitComment(permitComment, tokenAdminPrivateKey); + + _setupTokenAndSparks(permitComment); + + bytes32 expectedNonce = comments.nextNonce(); + + // any account can execute the permit comment on behalf of the tokenAdmin, + // it should be executed with 0 sparks + vm.prank(makeAddr("random account")); + comments.permitComment{value: 0}(permitComment, signature); + + IComments.CommentIdentifier memory expectedCommentIdentifier = IComments.CommentIdentifier({ + commenter: tokenAdmin, + contractAddress: address(mock1155), + tokenId: tokenId1, + nonce: expectedNonce + }); + _assertCommentExists(expectedCommentIdentifier); + } + + function testPermitComment_Not1155Holder() public { + IComments.PermitComment memory permitComment = _createPermitComment(collectorWithToken, "test comment"); + bytes memory signature = _signPermitComment(permitComment, collectorWithTokenPrivateKey); + + address executor = makeAddr("executor"); + vm.deal(executor, SPARKS_VALUE); + vm.expectRevert(IComments.NotTokenHolderOrAdmin.selector); + _executePermitComment(executor, permitComment, signature); + } + + function testPermitComment_SmartWalletOwner() public { + // Test scenario: + // We want to enable a smart wallet owner to comment when the smart wallet owns the token. + // - The smart wallet owns the 1155 token + // - privy account is an owner of the smart wallet + // - privy account is the one that signs the message + // - The comment is attributed to privy account (the smart wallet owner) + // We create a permit where: + // - commenter is privy account (smart wallet owner) + // - commenterSmartWallet is the smart wallet address + // - Privy account signs the message + // This should be a valid scenario, allowing a smart wallet owner to comment using a token owned by the smart wallet. + (address privyAccount, uint256 privyPrivateKey) = makeAddrAndKey("privy"); + + address smartWallet = address(new MockMultiOwnable(privyAccount)); + + mock1155.mint(smartWallet, tokenId1, 1, ""); + + IComments.PermitComment memory permitComment = IComments.PermitComment({ + // comment will be attributed to the smart wallet - it should be the one that + // has the signature checked against it. + commenter: privyAccount, + contractAddress: address(mock1155), + tokenId: tokenId1, + replyTo: IComments.CommentIdentifier({commenter: address(0), contractAddress: address(0), tokenId: 0, nonce: bytes32(0)}), + text: "smart wallet comment", + deadline: uint48(block.timestamp + 1000), + nonce: bytes32(0), + referrer: address(0), + sourceChainId: uint32(100), + destinationChainId: uint32(block.chainid), + // collectorWithToken is the smart wallet owner - this is the account that actually owns the 1155 token. + commenterSmartWallet: smartWallet + }); + + // sign the permit - but we have the another account (privy) sign for the smart wallet, since it is also an owner + bytes memory signature = _signPermitComment(permitComment, privyPrivateKey); + + IComments.CommentIdentifier memory expectedCommentIdentifier = _expectedCommentIdentifier( + permitComment.contractAddress, + permitComment.tokenId, + // privy account is the one that signs the message, but the smart wallet is the one that is commenting + privyAccount + ); + + address executor = makeAddr("executor"); + vm.deal(executor, SPARKS_VALUE); + _executePermitComment(executor, permitComment, signature); + + _assertCommentExists(expectedCommentIdentifier); + } + + function testPermitSparkCommentSparksComment() public { + IComments.PermitSparkComment memory permitSparkComment = _postCommentAndCreatePermitSparkComment(sparker, 3); + bytes memory signature = _signPermitSparkComment(permitSparkComment, sparkerPrivateKey); + + _setupSparkComment(permitSparkComment); + + address executor = makeAddr("executor"); + vm.deal(executor, permitSparkComment.sparksQuantity * SPARKS_VALUE); + + uint256 beforeSparksCount = comments.commentSparksQuantity(permitSparkComment.comment); + + vm.expectEmit(true, true, true, true); + emit IComments.SparkedComment( + comments.hashCommentIdentifier(permitSparkComment.comment), + permitSparkComment.comment, + permitSparkComment.sparksQuantity, + sparker, + block.timestamp, + permitSparkComment.referrer + ); + + _executePermitSparkComment(executor, permitSparkComment, signature); + + uint256 afterSparksCount = comments.commentSparksQuantity(permitSparkComment.comment); + assertEq(afterSparksCount, beforeSparksCount + permitSparkComment.sparksQuantity); + } + + function testPermitSparkComment_NonceUsedTwice() public { + IComments.PermitSparkComment memory permitSparkComment = _postCommentAndCreatePermitSparkComment(sparker, 3); + bytes memory signature = _signPermitSparkComment(permitSparkComment, sparkerPrivateKey); + + _setupSparkComment(permitSparkComment); + + // First spark should succeed + vm.deal(sparker, 10 ether); + _executePermitSparkComment(sparker, permitSparkComment, signature); + + // Second spark with same nonce should fail + vm.expectRevert(abi.encodeWithSelector(UnorderedNoncesUpgradeable.InvalidAccountNonce.selector, sparker, permitSparkComment.nonce)); + _executePermitSparkComment(sparker, permitSparkComment, signature); + } + + function testPermitSparkComment_DeadlineExpired() public { + IComments.PermitSparkComment memory permitSparkComment = _postCommentAndCreatePermitSparkComment(sparker, 3); + bytes memory signature = _signPermitSparkComment(permitSparkComment, sparkerPrivateKey); + + _setupSparkComment(permitSparkComment); + + // Warp time to after the deadline + vm.warp(permitSparkComment.deadline + 1); + + address executor = makeAddr("executor"); + vm.deal(executor, permitSparkComment.sparksQuantity * SPARKS_VALUE); + vm.expectRevert(abi.encodeWithSelector(IComments.ERC2612ExpiredSignature.selector, permitSparkComment.deadline)); + _executePermitSparkComment(executor, permitSparkComment, signature); + } + + function testPermitSparkComment_CommenterDoesntMatchSigner() public { + address wrongSigner; + uint256 wrongSignerPrivateKey; + (wrongSigner, wrongSignerPrivateKey) = makeAddrAndKey("wrongSigner"); + + IComments.PermitSparkComment memory permitSparkComment = _postCommentAndCreatePermitSparkComment(sparker, 3); + bytes memory wrongSignature = _signPermitSparkComment(permitSparkComment, wrongSignerPrivateKey); + + _setupSparkComment(permitSparkComment); + + vm.expectRevert(IComments.InvalidSignature.selector); + _executePermitSparkComment(makeAddr("executor"), permitSparkComment, wrongSignature); + } + + function testPermitSparkComment_ZeroSparks() public { + IComments.PermitSparkComment memory permitSparkComment = _postCommentAndCreatePermitSparkComment(sparker, 0); + bytes memory signature = _signPermitSparkComment(permitSparkComment, sparkerPrivateKey); + + _setupSparkComment(permitSparkComment); + + vm.expectRevert(IComments.MustSendAtLeastOneSpark.selector); + _executePermitSparkComment(collectorWithToken, permitSparkComment, signature); + } + + function testPermitSparkComment_IncorrectETHAmount() public { + IComments.PermitSparkComment memory permitSparkComment = _postCommentAndCreatePermitSparkComment(sparker, 3); + bytes memory signature = _signPermitSparkComment(permitSparkComment, sparkerPrivateKey); + + _setupSparkComment(permitSparkComment); + + address executor = makeAddr("executor"); + uint256 incorrectValue = (permitSparkComment.sparksQuantity * SPARKS_VALUE) + 1 wei; + vm.deal(executor, incorrectValue); + + vm.expectRevert( + abi.encodeWithSelector(IComments.IncorrectETHAmountForSparks.selector, incorrectValue, permitSparkComment.sparksQuantity * SPARKS_VALUE) + ); + vm.prank(executor); + comments.permitSparkComment{value: incorrectValue}(permitSparkComment, signature); + } + + function testPermitCommentCrossChain() public { + uint32 sourceChainId = 1; // Ethereum mainnet + uint32 destinationChainId = uint32(block.chainid); + + IComments.PermitComment memory permitComment = _createPermitComment(collectorWithToken, "cross-chain comment"); + permitComment.sourceChainId = sourceChainId; + permitComment.destinationChainId = destinationChainId; + + IComments.CommentIdentifier memory expectedCommentIdentifier = _expectedCommentIdentifier( + permitComment.contractAddress, + permitComment.tokenId, + permitComment.commenter + ); + + bytes memory signature = _signPermitComment(permitComment, collectorWithTokenPrivateKey); + + _setupTokenAndSparks(permitComment); + + address executor = makeAddr("executor"); + vm.deal(executor, SPARKS_VALUE); + + vm.expectEmit(true, true, true, true); + emit IComments.Commented( + comments.hashCommentIdentifier(expectedCommentIdentifier), + expectedCommentIdentifier, + bytes32(0), + permitComment.replyTo, + 1, + permitComment.text, + block.timestamp, + permitComment.referrer + ); + + _executePermitComment(executor, permitComment, signature); + + _assertCommentExists(expectedCommentIdentifier); + } + + function testPermitCommentCrossChainInvalidDestination() public { + uint32 sourceChainId = 1; // Ethereum mainnet + uint32 invalidDestinationChainId = 42; // Some other chain ID + + IComments.PermitComment memory permitComment = _createPermitComment(collectorWithToken, "invalid cross-chain comment"); + permitComment.sourceChainId = sourceChainId; + permitComment.destinationChainId = invalidDestinationChainId; + + bytes memory signature = _signPermitComment(permitComment, collectorWithTokenPrivateKey); + + _setupTokenAndSparks(permitComment); + + address executor = makeAddr("executor"); + vm.deal(executor, SPARKS_VALUE); + + vm.expectRevert(abi.encodeWithSelector(IComments.IncorrectDestinationChain.selector, invalidDestinationChainId)); + _executePermitComment(executor, permitComment, signature); + } + + function testPermitSparkCommentCrossChain() public { + uint32 sourceChainId = 1; // Ethereum mainnet + uint32 destinationChainId = uint32(block.chainid); + + IComments.PermitSparkComment memory permitSparkComment = _postCommentAndCreatePermitSparkComment(sparker, 3); + permitSparkComment.sourceChainId = sourceChainId; + permitSparkComment.destinationChainId = destinationChainId; + + bytes memory signature = _signPermitSparkComment(permitSparkComment, sparkerPrivateKey); + + _setupSparkComment(permitSparkComment); + + address executor = makeAddr("executor"); + vm.deal(executor, permitSparkComment.sparksQuantity * SPARKS_VALUE); + + uint256 beforeSparksCount = comments.commentSparksQuantity(permitSparkComment.comment); + + bytes32 commentId = comments.hashCommentIdentifier(permitSparkComment.comment); + + vm.expectEmit(true, true, true, true); + emit IComments.SparkedComment( + commentId, + permitSparkComment.comment, + permitSparkComment.sparksQuantity, + sparker, + block.timestamp, + permitSparkComment.referrer + ); + _executePermitSparkComment(executor, permitSparkComment, signature); + + uint256 afterSparksCount = comments.commentSparksQuantity(permitSparkComment.comment); + assertEq(afterSparksCount, beforeSparksCount + permitSparkComment.sparksQuantity); + } + + function testPermitSparkCommentCrossChainInvalidDestination() public { + uint32 sourceChainId = 1; // Ethereum mainnet + uint32 invalidDestinationChainId = 42; // Some other chain ID + + IComments.PermitSparkComment memory permitSparkComment = _postCommentAndCreatePermitSparkComment(sparker, 3); + permitSparkComment.sourceChainId = sourceChainId; + permitSparkComment.destinationChainId = invalidDestinationChainId; + + bytes memory signature = _signPermitSparkComment(permitSparkComment, sparkerPrivateKey); + + _setupSparkComment(permitSparkComment); + + address executor = makeAddr("executor"); + vm.deal(executor, permitSparkComment.sparksQuantity * SPARKS_VALUE); + + vm.expectRevert(abi.encodeWithSelector(IComments.IncorrectDestinationChain.selector, invalidDestinationChainId)); + _executePermitSparkComment(executor, permitSparkComment, signature); + } + + function _postCommentAndCreatePermitSparkComment(address _sparker, uint64 sparksQuantity) internal returns (IComments.PermitSparkComment memory) { + IComments.CommentIdentifier memory emptyReplyTo; + address commenter = makeAddr("commenter_b"); + IComments.CommentIdentifier memory commentIdentifier = _mockComment(commenter, emptyReplyTo); + + return _createPermitSparkComment(commentIdentifier, _sparker, sparksQuantity, uint32(block.chainid), uint32(block.chainid)); + } + + // Helper functions + function _createPermitSparkComment( + IComments.CommentIdentifier memory commentIdentifier, + address _sparker, + uint64 sparksQuantity, + uint32 sourceChainId, + uint32 destinationChainId + ) internal returns (IComments.PermitSparkComment memory) { + return + IComments.PermitSparkComment({ + comment: commentIdentifier, + sparker: _sparker, + sparksQuantity: sparksQuantity, + deadline: block.timestamp + 100, + nonce: bytes32("1"), + referrer: makeAddr("referrer"), + sourceChainId: sourceChainId, + destinationChainId: destinationChainId + }); + } + + function _signPermitSparkComment(IComments.PermitSparkComment memory permitSparkComment, uint256 privateKey) internal view returns (bytes memory) { + bytes32 digest = comments.hashPermitSparkComment(permitSparkComment); + return _sign(privateKey, digest); + } + + function _setupSparkComment(IComments.PermitSparkComment memory permitSparkComment) internal { + // For permitSparkComment, we don't need to mint a token + vm.deal(permitSparkComment.comment.commenter, permitSparkComment.sparksQuantity * SPARKS_VALUE); + } + + function _executePermitSparkComment(address executor, IComments.PermitSparkComment memory permitSparkComment, bytes memory signature) internal { + vm.prank(executor); + vm.deal(executor, permitSparkComment.sparksQuantity * SPARKS_VALUE); + comments.permitSparkComment{value: permitSparkComment.sparksQuantity * SPARKS_VALUE}(permitSparkComment, signature); + } + + // Helper functions + function _createPermitComment(address commenter, string memory text) internal returns (IComments.PermitComment memory) { + return + IComments.PermitComment({ + contractAddress: address(mock1155), + tokenId: tokenId1, + commenter: commenter, + replyTo: emptyCommentIdentifier, + text: text, + deadline: block.timestamp + 100, + nonce: bytes32("1"), + referrer: makeAddr("referrer"), + sourceChainId: uint32(block.chainid), + destinationChainId: uint32(block.chainid), + commenterSmartWallet: address(0) + }); + } + + function _setupTokenAndSparks(IComments.PermitComment memory permitComment) internal { + mock1155.mint(permitComment.commenter, permitComment.tokenId, 1, ""); + vm.deal(permitComment.commenter, SPARKS_VALUE); + } + + function _executePermitComment(address executor, IComments.PermitComment memory permitComment, bytes memory signature) internal { + vm.prank(executor); + comments.permitComment{value: SPARKS_VALUE}(permitComment, signature); + } + + function _assertCommentExists(IComments.CommentIdentifier memory commentIdentifier) internal view { + (, bool exists) = comments.hashAndCheckCommentExists(commentIdentifier); + assertTrue(exists); + } + + function _signPermitComment(IComments.PermitComment memory permitComment, uint256 privateKey) internal view returns (bytes memory signature) { + bytes32 digest = comments.hashPermitComment(permitComment); + signature = _sign(privateKey, digest); + } + + function _sign(uint256 privateKey, bytes32 digest) internal pure returns (bytes memory signature) { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, digest); + return abi.encodePacked(r, s, v); + } +} diff --git a/packages/comments/test/Comments_smartWallet.t.sol b/packages/comments/test/Comments_smartWallet.t.sol new file mode 100644 index 000000000..faed30046 --- /dev/null +++ b/packages/comments/test/Comments_smartWallet.t.sol @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {CommentsTestBase} from "./CommentsTestBase.sol"; +import {IMultiOwnable} from "../src/interfaces/IMultiOwnable.sol"; +import {IComments} from "../src/interfaces/IComments.sol"; +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {ERC1155Holder} from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; + +contract MockMultiOwnable is IMultiOwnable, ERC1155Holder { + bytes4 internal constant MAGIC_VALUE = bytes4(keccak256("isValidSignature(bytes32,bytes)")); + mapping(address => bool) public isOwner; + + constructor(address _owner) { + isOwner[_owner] = true; + } + + function addOwner(address _owner) external { + isOwner[_owner] = true; + } + + function isValidSignature(bytes32 _messageHash, bytes memory _signature) public view returns (bytes4 magicValue) { + address signatory = ECDSA.recover(_messageHash, _signature); + + if (isOwner[signatory]) { + return MAGIC_VALUE; + } else { + return bytes4(0); + } + } + + function isOwnerAddress(address account) external view returns (bool) { + return isOwner[account]; + } +} + +contract Comments_smartWallet is CommentsTestBase { + function test_commentWithSmartWalletOwner_whenHolder() public { + address smartWallet = address(new MockMultiOwnable(address(collectorWithoutToken))); + + mock1155.mint(smartWallet, tokenId1, 1, ""); + + IComments.CommentIdentifier memory expectedCommentIdentifier = _expectedCommentIdentifier(address(mock1155), tokenId1, collectorWithoutToken); + + vm.expectEmit(true, true, true, true); + emit IComments.Commented( + comments.hashCommentIdentifier(expectedCommentIdentifier), + expectedCommentIdentifier, + 0, + emptyCommentIdentifier, + 1, + "test comment", + block.timestamp, + address(0) + ); + vm.prank(collectorWithoutToken); + vm.deal(collectorWithoutToken, SPARKS_VALUE); + comments.comment{value: SPARKS_VALUE}({ + commenter: collectorWithoutToken, + contractAddress: address(mock1155), + tokenId: tokenId1, + text: "test comment", + replyTo: emptyCommentIdentifier, + commenterSmartWallet: smartWallet, + referrer: address(0) + }); + } + + function test_commentWithSmartWalletOwner_whenCreator() public { + address smartWallet = address(new MockMultiOwnable(address(collectorWithoutToken))); + + uint256 tokenId = 10; + mock1155.createToken(tokenId, address(smartWallet)); + + IComments.CommentIdentifier memory expectedCommentIdentifier = _expectedCommentIdentifier(address(mock1155), tokenId, collectorWithoutToken); + + vm.prank(collectorWithoutToken); + comments.comment({ + commenter: collectorWithoutToken, + contractAddress: address(mock1155), + tokenId: tokenId, + text: "test comment", + replyTo: emptyCommentIdentifier, + commenterSmartWallet: smartWallet, + referrer: address(0) + }); + + // check that comment was created + (, bool exists) = comments.hashAndCheckCommentExists(expectedCommentIdentifier); + assertTrue(exists); + } + + function test_commentWithSmartWalletOwner_revertsWhenNotHolder() public { + address smartWallet = address(new MockMultiOwnable(address(collectorWithoutToken))); + + IComments.CommentIdentifier memory emptyReplyTo; + + vm.expectRevert(abi.encodeWithSelector(IComments.NotTokenHolderOrAdmin.selector)); + vm.prank(collectorWithoutToken); + vm.deal(collectorWithoutToken, SPARKS_VALUE); + comments.comment{value: SPARKS_VALUE}({ + commenter: collectorWithoutToken, + contractAddress: address(mock1155), + tokenId: tokenId1, + text: "test comment", + replyTo: emptyReplyTo, + commenterSmartWallet: smartWallet, + referrer: address(0) + }); + } + + function test_commentWithSmartWalletOwner_revertsWhenNotOwner() public { + address smartWallet = address(new MockMultiOwnable(address(makeAddr("notOwner")))); + + mock1155.mint(smartWallet, tokenId1, 1, ""); + + IComments.CommentIdentifier memory emptyReplyTo; + + vm.expectRevert(IComments.NotSmartWalletOwner.selector); + vm.prank(collectorWithoutToken); + vm.deal(collectorWithoutToken, SPARKS_VALUE); + comments.comment{value: SPARKS_VALUE}({ + commenter: collectorWithoutToken, + contractAddress: address(mock1155), + tokenId: tokenId1, + text: "test comment", + replyTo: emptyReplyTo, + commenterSmartWallet: smartWallet, + referrer: address(0) + }); + } + + function test_commentWithSmartWalletOwner_revertsWhenNotSmartWallet() public { + address smartWallet = makeAddr("smartWallet"); + + mock1155.mint(smartWallet, tokenId1, 1, ""); + + IComments.CommentIdentifier memory emptyReplyTo; + + vm.expectRevert(IComments.NotSmartWallet.selector); + vm.prank(collectorWithoutToken); + comments.comment({ + commenter: collectorWithoutToken, + contractAddress: address(mock1155), + tokenId: tokenId1, + text: "test comment", + replyTo: emptyReplyTo, + commenterSmartWallet: smartWallet, + referrer: address(0) + }); + } +} diff --git a/packages/comments/test/mocks/Mock1155.sol b/packages/comments/test/mocks/Mock1155.sol new file mode 100644 index 000000000..f24cc6eb5 --- /dev/null +++ b/packages/comments/test/mocks/Mock1155.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {ERC1155} from "@openzeppelin/contracts/token/ERC1155/ERC1155.sol"; +import {IZoraCreator1155} from "../../src/interfaces/IZoraCreator1155.sol"; + +contract Mock1155 is ERC1155, IZoraCreator1155 { + /// @notice This user role allows for any action to be performed + uint256 public constant PERMISSION_BIT_ADMIN = 2 ** 1; + + /// @notice Global contract configuration + ContractConfig public config; + + mapping(uint256 => address) public admins; + + constructor() ERC1155("") {} + + function isAdminOrRole(address user, uint256 tokenId, uint256 role) external view returns (bool) { + if (admins[tokenId] == user && role == PERMISSION_BIT_ADMIN) { + return true; + } else { + return false; + } + } + + function _tokenExists(uint256 tokenId) internal view returns (bool) { + return admins[tokenId] != address(0); + } + + function getCreatorRewardRecipient(uint256 tokenId) external view returns (address) { + if (!_tokenExists(tokenId)) { + revert("Token does not exist"); + } + + return admins[tokenId]; + } + + function createToken(uint256 tokenId, address creator) external { + admins[tokenId] = creator; + } + + function supportsInterface(bytes4 interfaceId) public view override(ERC1155, IZoraCreator1155) returns (bool) { + return super.supportsInterface(interfaceId); + } + + function mint(address to, uint256 id, uint256 amount, bytes memory data) external payable { + if (!_tokenExists(id)) { + revert("Token does not exist"); + } + + _mint(to, id, amount, data); + } + + function owner() external view returns (address) { + return config.owner; + } + + function burn(address from, uint256 id, uint256 amount) external { + _safeTransferFrom(from, address(0), id, amount, ""); + } +} diff --git a/packages/comments/test/mocks/Mock1155NoCreatorRewardRecipient.sol b/packages/comments/test/mocks/Mock1155NoCreatorRewardRecipient.sol new file mode 100644 index 000000000..5cbe64600 --- /dev/null +++ b/packages/comments/test/mocks/Mock1155NoCreatorRewardRecipient.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {ERC1155} from "@openzeppelin/contracts/token/ERC1155/ERC1155.sol"; +import {IZoraCreator1155} from "./MockIZoraCreator1155.sol"; + +contract Mock1155NoCreatorRewardRecipient is ERC1155, IZoraCreator1155 { + /// @notice This user role allows for any action to be performed + uint256 public constant PERMISSION_BIT_ADMIN = 2 ** 1; + + /// @notice Global contract configuration + ContractConfig public config; + + mapping(uint256 => address) public admins; + + constructor() ERC1155("") {} + + function isAdminOrRole(address user, uint256 tokenId, uint256 role) external view returns (bool) { + if (admins[tokenId] == user && role == PERMISSION_BIT_ADMIN) { + return true; + } else { + return false; + } + } + + function _tokenExists(uint256 tokenId) internal view returns (bool) { + return admins[tokenId] != address(0); + } + + function createToken(uint256 tokenId, address creator) external { + admins[tokenId] = creator; + } + + function supportsInterface(bytes4 interfaceId) public view override(ERC1155, IZoraCreator1155) returns (bool) { + return super.supportsInterface(interfaceId); + } + + function mint(address to, uint256 id, uint256 amount, bytes memory data) external { + if (!_tokenExists(id)) { + revert("Token does not exist"); + } + + _mint(to, id, amount, data); + } + + function _setFundsRecipient(address payable fundsRecipient) internal { + config.fundsRecipient = fundsRecipient; + } + + function setFundsRecipient(address payable fundsRecipient) external { + _setFundsRecipient(fundsRecipient); + } + + function owner() external view returns (address) { + return config.owner; + } + + function _setOwner(address newOwner) internal { + config.owner = newOwner; + } + + function setOwner(address newOwner) external { + _setOwner(newOwner); + } +} diff --git a/packages/comments/test/mocks/Mock1155NoOwner.sol b/packages/comments/test/mocks/Mock1155NoOwner.sol new file mode 100644 index 000000000..7b05ce93b --- /dev/null +++ b/packages/comments/test/mocks/Mock1155NoOwner.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {ERC1155} from "@openzeppelin/contracts/token/ERC1155/ERC1155.sol"; +import {IERC1155} from "@openzeppelin/contracts/interfaces/IERC1155.sol"; +import {IZoraCreator1155TypesV1} from "../../src/interfaces/IZoraCreator1155TypesV1.sol"; + +// For testing without getCreatorRewardRecipient +interface IZoraCreator1155 is IERC1155, IZoraCreator1155TypesV1 { + function isAdminOrRole(address user, uint256 tokenId, uint256 role) external view returns (bool); + + function supportsInterface(bytes4 interfaceId) external view returns (bool); +} + +contract Mock1155NoOwner is ERC1155, IZoraCreator1155 { + /// @notice This user role allows for any action to be performed + uint256 public constant PERMISSION_BIT_ADMIN = 2 ** 1; + + /// @notice Global contract configuration + ContractConfig public config; + + mapping(uint256 => address) public admins; + + constructor() ERC1155("") {} + + function isAdminOrRole(address user, uint256 tokenId, uint256 role) external view returns (bool) { + if (admins[tokenId] == user && role == PERMISSION_BIT_ADMIN) { + return true; + } else { + return false; + } + } + + function _tokenExists(uint256 tokenId) internal view returns (bool) { + return admins[tokenId] != address(0); + } + + function createToken(uint256 tokenId, address creator) external { + admins[tokenId] = creator; + } + + function supportsInterface(bytes4 interfaceId) public view override(ERC1155, IZoraCreator1155) returns (bool) { + return super.supportsInterface(interfaceId); + } + + function mint(address to, uint256 id, uint256 amount, bytes memory data) external { + if (!_tokenExists(id)) { + revert("Token does not exist"); + } + + _mint(to, id, amount, data); + } +} diff --git a/packages/comments/test/mocks/MockDelegateCommenter.sol b/packages/comments/test/mocks/MockDelegateCommenter.sol new file mode 100644 index 000000000..41beaeeb8 --- /dev/null +++ b/packages/comments/test/mocks/MockDelegateCommenter.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {IComments} from "../../src/interfaces/IComments.sol"; +import {Mock1155} from "./Mock1155.sol"; + +contract MockDelegateCommenter { + IComments immutable comments; + uint256 MINT_FEE = 0.000111 ether; + uint256 constant SPARKS_VALUE = 0.000001 ether; + + constructor(address _comments) { + comments = IComments(_comments); + } + + IComments.CommentIdentifier emptyCommentIdentifier; + + function forwardComment(address collection, uint256 tokenId, string calldata comment) external { + comments.delegateComment(msg.sender, collection, tokenId, comment, emptyCommentIdentifier, address(0), address(0)); + } + + function mintAndCommentWithSpark( + uint256 quantity, + address collection, + uint256 tokenId, + string calldata comment, + address referrer, + uint256 sparksQuantity + ) external payable { + require(msg.value == SPARKS_VALUE * sparksQuantity + MINT_FEE * quantity, "Invalid value"); + Mock1155(collection).mint(msg.sender, tokenId, quantity, ""); + + // get sparks value to send to comments contract + comments.delegateComment{value: SPARKS_VALUE * sparksQuantity}(msg.sender, collection, tokenId, comment, emptyCommentIdentifier, address(0), referrer); + } +} diff --git a/packages/comments/test/mocks/MockIZoraCreator1155.sol b/packages/comments/test/mocks/MockIZoraCreator1155.sol new file mode 100644 index 000000000..a8b09fab9 --- /dev/null +++ b/packages/comments/test/mocks/MockIZoraCreator1155.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import {IERC1155} from "@openzeppelin/contracts/interfaces/IERC1155.sol"; +import {IZoraCreator1155TypesV1} from "../../src/interfaces/IZoraCreator1155TypesV1.sol"; + +// For testing without getCreatorRewardRecipient +interface IZoraCreator1155 is IERC1155, IZoraCreator1155TypesV1 { + function isAdminOrRole(address user, uint256 tokenId, uint256 role) external view returns (bool); + + function supportsInterface(bytes4 interfaceId) external view returns (bool); + + function config() external view returns (address owner, uint96 __gap1, address payable fundsRecipient, uint96 __gap2, address transferHook, uint96 __gap3); + + function setFundsRecipient(address payable fundsRecipient) external; +} diff --git a/packages/comments/test/mocks/MockSecondarySwap.sol b/packages/comments/test/mocks/MockSecondarySwap.sol new file mode 100644 index 000000000..bc92cdd8b --- /dev/null +++ b/packages/comments/test/mocks/MockSecondarySwap.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {ISecondarySwap} from "../../src/interfaces/ISecondarySwap.sol"; +import {Mock1155} from "./Mock1155.sol"; +import {MockZoraTimedSale} from "./MockZoraTimedSale.sol"; +import {IERC1155Receiver} from "@openzeppelin/contracts/interfaces/IERC1155Receiver.sol"; + +contract MockSecondarySwap is ISecondarySwap { + MockZoraTimedSale immutable mockTimedSale; + + constructor(MockZoraTimedSale _mockTimedSale) { + mockTimedSale = _mockTimedSale; + } + + function buy1155(address erc20zAddress, uint256 num1155ToBuy, address payable recipient, address payable, uint256, uint160) external payable { + (address collection, uint256 tokenId) = mockTimedSale.collectionForErc20z(erc20zAddress); + if (collection == address(0)) revert("ERC20z not set"); + Mock1155(collection).mint(recipient, tokenId, num1155ToBuy, ""); + } + + function onERC1155Received(address, address, uint256 tokenId, uint256, bytes calldata) external view returns (bytes4) { + address collection = msg.sender; + address erc20zAddress = mockTimedSale.sale(collection, tokenId).erc20zAddress; + if (erc20zAddress == address(0)) { + revert SaleNotSet(); + } + return IERC1155Receiver.onERC1155Received.selector; + } +} diff --git a/packages/comments/test/mocks/MockZoraTimedSale.sol b/packages/comments/test/mocks/MockZoraTimedSale.sol new file mode 100644 index 000000000..0571c7774 --- /dev/null +++ b/packages/comments/test/mocks/MockZoraTimedSale.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Mock1155} from "./Mock1155.sol"; +import {IZoraTimedSaleStrategy} from "../../src/interfaces/IZoraTimedSaleStrategy.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract MockERC20z is ERC20 { + constructor() ERC20("ERC20z", "ERC20z") {} +} + +contract MockZoraTimedSale is IZoraTimedSaleStrategy { + uint256 constant MINT_FEE = 0.000111 ether; + + function mint(address mintTo, uint256 quantity, address collection, uint256 tokenId, address, string calldata) external payable { + if (msg.value != MINT_FEE * quantity) revert("Incorrect mint fee"); + Mock1155(collection).mint{value: msg.value}(mintTo, tokenId, quantity, ""); + } + + struct CollectionAndTokenId { + address collection; + uint256 tokenId; + } + + mapping(address => mapping(uint256 => address)) internal saleStorage; + mapping(address => CollectionAndTokenId) public collectionForErc20z; + + function setSale(address collection, uint256 tokenId) external returns (address erc20z) { + erc20z = address(new MockERC20z()); + saleStorage[collection][tokenId] = erc20z; + collectionForErc20z[erc20z] = CollectionAndTokenId(collection, tokenId); + } + + function sale(address collection, uint256 tokenId) external view returns (SaleStorage memory _sale) { + address erc20z = saleStorage[collection][tokenId]; + _sale.erc20zAddress = payable(erc20z); + } +} diff --git a/packages/comments/test/mocks/ProtocolRewards.sol b/packages/comments/test/mocks/ProtocolRewards.sol new file mode 100644 index 000000000..816222589 --- /dev/null +++ b/packages/comments/test/mocks/ProtocolRewards.sol @@ -0,0 +1,1497 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +// packages/protocol-rewards/_imagine/Enjoy.sol + +/* + + ░░░░░░░░░░░░░░ + ░░▒▒░░░░░░░░░░░░░░░░░░░░ + ░░▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░ + ░░▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░░ + ░▓▓▒▒▒▒░░░░░░░░░░░░ ░░░░░░░ + ░▓▓▓▒▒▒▒░░░░░░░░░░░░ ░░░░░░░░ + ░▓▓▓▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░░░░ + ░▓▓▓▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░ + ░▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░ + ░▓▓▓▓▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░ + ░░▓▓▓▓▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░ + ░░▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒░░ + ░░▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ + ░░▓▓▓▓▓▓▓▓▓▓▓▓▒▒░░░ + + OURS TRULY, + + */ + +interface Enjoy {} + +// packages/protocol-rewards/src/interfaces/IProtocolRewards.sol + +/// @title IProtocolRewards +/// @notice The interface for deposits & withdrawals for Protocol Rewards +interface IProtocolRewards { + /// @notice Rewards Deposit Event + /// @param creator Creator for NFT rewards + /// @param createReferral Creator referral + /// @param mintReferral Mint referral user + /// @param firstMinter First minter reward recipient + /// @param zora ZORA recipient + /// @param from The caller of the deposit + /// @param creatorReward Creator reward amount + /// @param createReferralReward Creator referral reward + /// @param mintReferralReward Mint referral amount + /// @param firstMinterReward First minter reward amount + /// @param zoraReward ZORA amount + event RewardsDeposit( + address indexed creator, + address indexed createReferral, + address indexed mintReferral, + address firstMinter, + address zora, + address from, + uint256 creatorReward, + uint256 createReferralReward, + uint256 mintReferralReward, + uint256 firstMinterReward, + uint256 zoraReward + ); + + /// @notice Deposit Event + /// @param from From user + /// @param to To user (within contract) + /// @param reason Optional bytes4 reason for indexing + /// @param amount Amount of deposit + /// @param comment Optional user comment + event Deposit(address indexed from, address indexed to, bytes4 indexed reason, uint256 amount, string comment); + + /// @notice Withdraw Event + /// @param from From user + /// @param to To user (within contract) + /// @param amount Amount of deposit + event Withdraw(address indexed from, address indexed to, uint256 amount); + + /// @notice Cannot send to address zero + error ADDRESS_ZERO(); + + /// @notice Function argument array length mismatch + error ARRAY_LENGTH_MISMATCH(); + + /// @notice Invalid deposit + error INVALID_DEPOSIT(); + + /// @notice Invalid signature for deposit + error INVALID_SIGNATURE(); + + /// @notice Invalid withdraw + error INVALID_WITHDRAW(); + + /// @notice Signature for withdraw is too old and has expired + error SIGNATURE_DEADLINE_EXPIRED(); + + /// @notice Low-level ETH transfer has failed + error TRANSFER_FAILED(); + + /// @notice Generic function to deposit ETH for a recipient, with an optional comment + /// @param to Address to deposit to + /// @param to Reason system reason for deposit (used for indexing) + /// @param comment Optional comment as reason for deposit + function deposit(address to, bytes4 why, string calldata comment) external payable; + + /// @notice Generic function to deposit ETH for multiple recipients, with an optional comment + /// @param recipients recipients to send the amount to, array aligns with amounts + /// @param amounts amounts to send to each recipient, array aligns with recipients + /// @param reasons optional bytes4 hash for indexing + /// @param comment Optional comment to include with mint + function depositBatch(address[] calldata recipients, uint256[] calldata amounts, bytes4[] calldata reasons, string calldata comment) external payable; + + /// @notice Used by Zora ERC-721 & ERC-1155 contracts to deposit protocol rewards + /// @param creator Creator for NFT rewards + /// @param creatorReward Creator reward amount + /// @param createReferral Creator referral + /// @param createReferralReward Creator referral reward + /// @param mintReferral Mint referral user + /// @param mintReferralReward Mint referral amount + /// @param firstMinter First minter reward + /// @param firstMinterReward First minter reward amount + /// @param zora ZORA recipient + /// @param zoraReward ZORA amount + function depositRewards( + address creator, + uint256 creatorReward, + address createReferral, + uint256 createReferralReward, + address mintReferral, + uint256 mintReferralReward, + address firstMinter, + uint256 firstMinterReward, + address zora, + uint256 zoraReward + ) external payable; + + /// @notice Withdraw protocol rewards + /// @param to Withdraws from msg.sender to this address + /// @param amount amount to withdraw + function withdraw(address to, uint256 amount) external; + + /// @notice Execute a withdraw of protocol rewards via signature + /// @param from Withdraw from this address + /// @param to Withdraw to this address + /// @param amount Amount to withdraw + /// @param deadline Deadline for the signature to be valid + /// @param v V component of signature + /// @param r R component of signature + /// @param s S component of signature + function withdrawWithSig(address from, address to, uint256 amount, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external; +} + +// packages/protocol-rewards/src/lib/IERC5267.sol + +// OpenZeppelin Contracts (last updated v4.9.0) (interfaces/IERC5267.sol) + +interface IERC5267 { + /** + * @dev MAY be emitted to signal that the domain could have changed. + */ + event EIP712DomainChanged(); + + /** + * @dev returns the fields and values that describe the domain separator used by this contract for EIP-712 + * signature. + */ + function eip712Domain() + external + view + returns ( + bytes1 fields, + string memory name, + string memory version, + uint256 chainId, + address verifyingContract, + bytes32 salt, + uint256[] memory extensions + ); +} + +// packages/protocol-rewards/src/lib/Math.sol + +// OpenZeppelin Contracts (last updated v4.9.0) (utils/math/Math.sol) + +/** + * @dev Standard math utilities missing in the Solidity language. + */ +library Math { + enum Rounding { + Down, // Toward negative infinity + Up, // Toward infinity + Zero // Toward zero + } + + /** + * @dev Returns the largest of two numbers. + */ + function max(uint256 a, uint256 b) internal pure returns (uint256) { + return a > b ? a : b; + } + + /** + * @dev Returns the smallest of two numbers. + */ + function min(uint256 a, uint256 b) internal pure returns (uint256) { + return a < b ? a : b; + } + + /** + * @dev Returns the average of two numbers. The result is rounded towards + * zero. + */ + function average(uint256 a, uint256 b) internal pure returns (uint256) { + // (a + b) / 2 can overflow. + return (a & b) + (a ^ b) / 2; + } + + /** + * @dev Returns the ceiling of the division of two numbers. + * + * This differs from standard division with `/` in that it rounds up instead + * of rounding down. + */ + function ceilDiv(uint256 a, uint256 b) internal pure returns (uint256) { + // (a + b - 1) / b can overflow on addition, so we distribute. + return a == 0 ? 0 : (a - 1) / b + 1; + } + + /** + * @notice Calculates floor(x * y / denominator) with full precision. Throws if result overflows a uint256 or denominator == 0 + * @dev Original credit to Remco Bloemen under MIT license (https://xn--2-umb.com/21/muldiv) + * with further edits by Uniswap Labs also under MIT license. + */ + function mulDiv(uint256 x, uint256 y, uint256 denominator) internal pure returns (uint256 result) { + unchecked { + // 512-bit multiply [prod1 prod0] = x * y. Compute the product mod 2^256 and mod 2^256 - 1, then use + // use the Chinese Remainder Theorem to reconstruct the 512 bit result. The result is stored in two 256 + // variables such that product = prod1 * 2^256 + prod0. + uint256 prod0; // Least significant 256 bits of the product + uint256 prod1; // Most significant 256 bits of the product + assembly { + let mm := mulmod(x, y, not(0)) + prod0 := mul(x, y) + prod1 := sub(sub(mm, prod0), lt(mm, prod0)) + } + + // Handle non-overflow cases, 256 by 256 division. + if (prod1 == 0) { + // Solidity will revert if denominator == 0, unlike the div opcode on its own. + // The surrounding unchecked block does not change this fact. + // See https://docs.soliditylang.org/en/latest/control-structures.html#checked-or-unchecked-arithmetic. + return prod0 / denominator; + } + + // Make sure the result is less than 2^256. Also prevents denominator == 0. + require(denominator > prod1, "Math: mulDiv overflow"); + + /////////////////////////////////////////////// + // 512 by 256 division. + /////////////////////////////////////////////// + + // Make division exact by subtracting the remainder from [prod1 prod0]. + uint256 remainder; + assembly { + // Compute remainder using mulmod. + remainder := mulmod(x, y, denominator) + + // Subtract 256 bit number from 512 bit number. + prod1 := sub(prod1, gt(remainder, prod0)) + prod0 := sub(prod0, remainder) + } + + // Factor powers of two out of denominator and compute largest power of two divisor of denominator. Always >= 1. + // See https://cs.stackexchange.com/q/138556/92363. + + // Does not overflow because the denominator cannot be zero at this stage in the function. + uint256 twos = denominator & (~denominator + 1); + assembly { + // Divide denominator by twos. + denominator := div(denominator, twos) + + // Divide [prod1 prod0] by twos. + prod0 := div(prod0, twos) + + // Flip twos such that it is 2^256 / twos. If twos is zero, then it becomes one. + twos := add(div(sub(0, twos), twos), 1) + } + + // Shift in bits from prod1 into prod0. + prod0 |= prod1 * twos; + + // Invert denominator mod 2^256. Now that denominator is an odd number, it has an inverse modulo 2^256 such + // that denominator * inv = 1 mod 2^256. Compute the inverse by starting with a seed that is correct for + // four bits. That is, denominator * inv = 1 mod 2^4. + uint256 inverse = (3 * denominator) ^ 2; + + // Use the Newton-Raphson iteration to improve the precision. Thanks to Hensel's lifting lemma, this also works + // in modular arithmetic, doubling the correct bits in each step. + inverse *= 2 - denominator * inverse; // inverse mod 2^8 + inverse *= 2 - denominator * inverse; // inverse mod 2^16 + inverse *= 2 - denominator * inverse; // inverse mod 2^32 + inverse *= 2 - denominator * inverse; // inverse mod 2^64 + inverse *= 2 - denominator * inverse; // inverse mod 2^128 + inverse *= 2 - denominator * inverse; // inverse mod 2^256 + + // Because the division is now exact we can divide by multiplying with the modular inverse of denominator. + // This will give us the correct result modulo 2^256. Since the preconditions guarantee that the outcome is + // less than 2^256, this is the final result. We don't need to compute the high bits of the result and prod1 + // is no longer required. + result = prod0 * inverse; + return result; + } + } + + /** + * @notice Calculates x * y / denominator with full precision, following the selected rounding direction. + */ + function mulDiv(uint256 x, uint256 y, uint256 denominator, Rounding rounding) internal pure returns (uint256) { + uint256 result = mulDiv(x, y, denominator); + if (rounding == Rounding.Up && mulmod(x, y, denominator) > 0) { + result += 1; + } + return result; + } + + /** + * @dev Returns the square root of a number. If the number is not a perfect square, the value is rounded down. + * + * Inspired by Henry S. Warren, Jr.'s "Hacker's Delight" (Chapter 11). + */ + function sqrt(uint256 a) internal pure returns (uint256) { + if (a == 0) { + return 0; + } + + // For our first guess, we get the biggest power of 2 which is smaller than the square root of the target. + // + // We know that the "msb" (most significant bit) of our target number `a` is a power of 2 such that we have + // `msb(a) <= a < 2*msb(a)`. This value can be written `msb(a)=2**k` with `k=log2(a)`. + // + // This can be rewritten `2**log2(a) <= a < 2**(log2(a) + 1)` + // → `sqrt(2**k) <= sqrt(a) < sqrt(2**(k+1))` + // → `2**(k/2) <= sqrt(a) < 2**((k+1)/2) <= 2**(k/2 + 1)` + // + // Consequently, `2**(log2(a) / 2)` is a good first approximation of `sqrt(a)` with at least 1 correct bit. + uint256 result = 1 << (log2(a) >> 1); + + // At this point `result` is an estimation with one bit of precision. We know the true value is a uint128, + // since it is the square root of a uint256. Newton's method converges quadratically (precision doubles at + // every iteration). We thus need at most 7 iteration to turn our partial result with one bit of precision + // into the expected uint128 result. + unchecked { + result = (result + a / result) >> 1; + result = (result + a / result) >> 1; + result = (result + a / result) >> 1; + result = (result + a / result) >> 1; + result = (result + a / result) >> 1; + result = (result + a / result) >> 1; + result = (result + a / result) >> 1; + return min(result, a / result); + } + } + + /** + * @notice Calculates sqrt(a), following the selected rounding direction. + */ + function sqrt(uint256 a, Rounding rounding) internal pure returns (uint256) { + unchecked { + uint256 result = sqrt(a); + return result + (rounding == Rounding.Up && result * result < a ? 1 : 0); + } + } + + /** + * @dev Return the log in base 2, rounded down, of a positive value. + * Returns 0 if given 0. + */ + function log2(uint256 value) internal pure returns (uint256) { + uint256 result = 0; + unchecked { + if (value >> 128 > 0) { + value >>= 128; + result += 128; + } + if (value >> 64 > 0) { + value >>= 64; + result += 64; + } + if (value >> 32 > 0) { + value >>= 32; + result += 32; + } + if (value >> 16 > 0) { + value >>= 16; + result += 16; + } + if (value >> 8 > 0) { + value >>= 8; + result += 8; + } + if (value >> 4 > 0) { + value >>= 4; + result += 4; + } + if (value >> 2 > 0) { + value >>= 2; + result += 2; + } + if (value >> 1 > 0) { + result += 1; + } + } + return result; + } + + /** + * @dev Return the log in base 2, following the selected rounding direction, of a positive value. + * Returns 0 if given 0. + */ + function log2(uint256 value, Rounding rounding) internal pure returns (uint256) { + unchecked { + uint256 result = log2(value); + return result + (rounding == Rounding.Up && 1 << result < value ? 1 : 0); + } + } + + /** + * @dev Return the log in base 10, rounded down, of a positive value. + * Returns 0 if given 0. + */ + function log10(uint256 value) internal pure returns (uint256) { + uint256 result = 0; + unchecked { + if (value >= 10 ** 64) { + value /= 10 ** 64; + result += 64; + } + if (value >= 10 ** 32) { + value /= 10 ** 32; + result += 32; + } + if (value >= 10 ** 16) { + value /= 10 ** 16; + result += 16; + } + if (value >= 10 ** 8) { + value /= 10 ** 8; + result += 8; + } + if (value >= 10 ** 4) { + value /= 10 ** 4; + result += 4; + } + if (value >= 10 ** 2) { + value /= 10 ** 2; + result += 2; + } + if (value >= 10 ** 1) { + result += 1; + } + } + return result; + } + + /** + * @dev Return the log in base 10, following the selected rounding direction, of a positive value. + * Returns 0 if given 0. + */ + function log10(uint256 value, Rounding rounding) internal pure returns (uint256) { + unchecked { + uint256 result = log10(value); + return result + (rounding == Rounding.Up && 10 ** result < value ? 1 : 0); + } + } + + /** + * @dev Return the log in base 256, rounded down, of a positive value. + * Returns 0 if given 0. + * + * Adding one to the result gives the number of pairs of hex symbols needed to represent `value` as a hex string. + */ + function log256(uint256 value) internal pure returns (uint256) { + uint256 result = 0; + unchecked { + if (value >> 128 > 0) { + value >>= 128; + result += 16; + } + if (value >> 64 > 0) { + value >>= 64; + result += 8; + } + if (value >> 32 > 0) { + value >>= 32; + result += 4; + } + if (value >> 16 > 0) { + value >>= 16; + result += 2; + } + if (value >> 8 > 0) { + result += 1; + } + } + return result; + } + + /** + * @dev Return the log in base 256, following the selected rounding direction, of a positive value. + * Returns 0 if given 0. + */ + function log256(uint256 value, Rounding rounding) internal pure returns (uint256) { + unchecked { + uint256 result = log256(value); + return result + (rounding == Rounding.Up && 1 << (result << 3) < value ? 1 : 0); + } + } +} + +// packages/protocol-rewards/src/lib/SignedMath.sol + +// OpenZeppelin Contracts (last updated v4.8.0) (utils/math/SignedMath.sol) + +/** + * @dev Standard signed math utilities missing in the Solidity language. + */ +library SignedMath { + /** + * @dev Returns the largest of two signed numbers. + */ + function max(int256 a, int256 b) internal pure returns (int256) { + return a > b ? a : b; + } + + /** + * @dev Returns the smallest of two signed numbers. + */ + function min(int256 a, int256 b) internal pure returns (int256) { + return a < b ? a : b; + } + + /** + * @dev Returns the average of two signed numbers without overflow. + * The result is rounded towards zero. + */ + function average(int256 a, int256 b) internal pure returns (int256) { + // Formula from the book "Hacker's Delight" + int256 x = (a & b) + ((a ^ b) >> 1); + return x + (int256(uint256(x) >> 255) & (a ^ b)); + } + + /** + * @dev Returns the absolute unsigned value of a signed value. + */ + function abs(int256 n) internal pure returns (uint256) { + unchecked { + // must be unchecked in order to support `n = type(int256).min` + return uint256(n >= 0 ? n : -n); + } + } +} + +// packages/protocol-rewards/src/lib/StorageSlot.sol + +// OpenZeppelin Contracts (last updated v4.9.0) (utils/StorageSlot.sol) +// This file was procedurally generated from scripts/generate/templates/StorageSlot.js. + +/** + * @dev Library for reading and writing primitive types to specific storage slots. + * + * Storage slots are often used to avoid storage conflict when dealing with upgradeable contracts. + * This library helps with reading and writing to such slots without the need for inline assembly. + * + * The functions in this library return Slot structs that contain a `value` member that can be used to read or write. + * + * Example usage to set ERC1967 implementation slot: + * ```solidity + * contract ERC1967 { + * bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + * + * function _getImplementation() internal view returns (address) { + * return StorageSlot.getAddressSlot(_IMPLEMENTATION_SLOT).value; + * } + * + * function _setImplementation(address newImplementation) internal { + * require(Address.isContract(newImplementation), "ERC1967: new implementation is not a contract"); + * StorageSlot.getAddressSlot(_IMPLEMENTATION_SLOT).value = newImplementation; + * } + * } + * ``` + * + * _Available since v4.1 for `address`, `bool`, `bytes32`, `uint256`._ + * _Available since v4.9 for `string`, `bytes`._ + */ +library StorageSlot { + struct AddressSlot { + address value; + } + + struct BooleanSlot { + bool value; + } + + struct Bytes32Slot { + bytes32 value; + } + + struct Uint256Slot { + uint256 value; + } + + struct StringSlot { + string value; + } + + struct BytesSlot { + bytes value; + } + + /** + * @dev Returns an `AddressSlot` with member `value` located at `slot`. + */ + function getAddressSlot(bytes32 slot) internal pure returns (AddressSlot storage r) { + /// @solidity memory-safe-assembly + assembly { + r.slot := slot + } + } + + /** + * @dev Returns an `BooleanSlot` with member `value` located at `slot`. + */ + function getBooleanSlot(bytes32 slot) internal pure returns (BooleanSlot storage r) { + /// @solidity memory-safe-assembly + assembly { + r.slot := slot + } + } + + /** + * @dev Returns an `Bytes32Slot` with member `value` located at `slot`. + */ + function getBytes32Slot(bytes32 slot) internal pure returns (Bytes32Slot storage r) { + /// @solidity memory-safe-assembly + assembly { + r.slot := slot + } + } + + /** + * @dev Returns an `Uint256Slot` with member `value` located at `slot`. + */ + function getUint256Slot(bytes32 slot) internal pure returns (Uint256Slot storage r) { + /// @solidity memory-safe-assembly + assembly { + r.slot := slot + } + } + + /** + * @dev Returns an `StringSlot` with member `value` located at `slot`. + */ + function getStringSlot(bytes32 slot) internal pure returns (StringSlot storage r) { + /// @solidity memory-safe-assembly + assembly { + r.slot := slot + } + } + + /** + * @dev Returns an `StringSlot` representation of the string storage pointer `store`. + */ + function getStringSlot(string storage store) internal pure returns (StringSlot storage r) { + /// @solidity memory-safe-assembly + assembly { + r.slot := store.slot + } + } + + /** + * @dev Returns an `BytesSlot` with member `value` located at `slot`. + */ + function getBytesSlot(bytes32 slot) internal pure returns (BytesSlot storage r) { + /// @solidity memory-safe-assembly + assembly { + r.slot := slot + } + } + + /** + * @dev Returns an `BytesSlot` representation of the bytes storage pointer `store`. + */ + function getBytesSlot(bytes storage store) internal pure returns (BytesSlot storage r) { + /// @solidity memory-safe-assembly + assembly { + r.slot := store.slot + } + } +} + +// packages/protocol-rewards/src/lib/ShortStrings.sol + +// OpenZeppelin Contracts (last updated v4.9.0) (utils/ShortStrings.sol) + +// | string | 0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA | +// | length | 0x BB | +type ShortString is bytes32; + +/** + * @dev This library provides functions to convert short memory strings + * into a `ShortString` type that can be used as an immutable variable. + * + * Strings of arbitrary length can be optimized using this library if + * they are short enough (up to 31 bytes) by packing them with their + * length (1 byte) in a single EVM word (32 bytes). Additionally, a + * fallback mechanism can be used for every other case. + * + * Usage example: + * + * ```solidity + * contract Named { + * using ShortStrings for *; + * + * ShortString private immutable _name; + * string private _nameFallback; + * + * constructor(string memory contractName) { + * _name = contractName.toShortStringWithFallback(_nameFallback); + * } + * + * function name() external view returns (string memory) { + * return _name.toStringWithFallback(_nameFallback); + * } + * } + * ``` + */ +library ShortStrings { + // Used as an identifier for strings longer than 31 bytes. + bytes32 private constant _FALLBACK_SENTINEL = 0x00000000000000000000000000000000000000000000000000000000000000FF; + + error StringTooLong(string str); + error InvalidShortString(); + + /** + * @dev Encode a string of at most 31 chars into a `ShortString`. + * + * This will trigger a `StringTooLong` error is the input string is too long. + */ + function toShortString(string memory str) internal pure returns (ShortString) { + bytes memory bstr = bytes(str); + if (bstr.length > 31) { + revert StringTooLong(str); + } + return ShortString.wrap(bytes32(uint256(bytes32(bstr)) | bstr.length)); + } + + /** + * @dev Decode a `ShortString` back to a "normal" string. + */ + function toString(ShortString sstr) internal pure returns (string memory) { + uint256 len = byteLength(sstr); + // using `new string(len)` would work locally but is not memory safe. + string memory str = new string(32); + /// @solidity memory-safe-assembly + assembly { + mstore(str, len) + mstore(add(str, 0x20), sstr) + } + return str; + } + + /** + * @dev Return the length of a `ShortString`. + */ + function byteLength(ShortString sstr) internal pure returns (uint256) { + uint256 result = uint256(ShortString.unwrap(sstr)) & 0xFF; + if (result > 31) { + revert InvalidShortString(); + } + return result; + } + + /** + * @dev Encode a string into a `ShortString`, or write it to storage if it is too long. + */ + function toShortStringWithFallback(string memory value, string storage store) internal returns (ShortString) { + if (bytes(value).length < 32) { + return toShortString(value); + } else { + StorageSlot.getStringSlot(store).value = value; + return ShortString.wrap(_FALLBACK_SENTINEL); + } + } + + /** + * @dev Decode a string that was encoded to `ShortString` or written to storage using {setWithFallback}. + */ + function toStringWithFallback(ShortString value, string storage store) internal pure returns (string memory) { + if (ShortString.unwrap(value) != _FALLBACK_SENTINEL) { + return toString(value); + } else { + return store; + } + } + + /** + * @dev Return the length of a string that was encoded to `ShortString` or written to storage using {setWithFallback}. + * + * WARNING: This will return the "byte length" of the string. This may not reflect the actual length in terms of + * actual characters as the UTF-8 encoding of a single character can span over multiple bytes. + */ + function byteLengthWithFallback(ShortString value, string storage store) internal view returns (uint256) { + if (ShortString.unwrap(value) != _FALLBACK_SENTINEL) { + return byteLength(value); + } else { + return bytes(store).length; + } + } +} + +// packages/protocol-rewards/src/lib/Strings.sol + +// OpenZeppelin Contracts (last updated v4.9.0) (utils/Strings.sol) + +/** + * @dev String operations. + */ +library Strings { + bytes16 private constant _SYMBOLS = "0123456789abcdef"; + uint8 private constant _ADDRESS_LENGTH = 20; + + /** + * @dev Converts a `uint256` to its ASCII `string` decimal representation. + */ + function toString(uint256 value) internal pure returns (string memory) { + unchecked { + uint256 length = Math.log10(value) + 1; + string memory buffer = new string(length); + uint256 ptr; + /// @solidity memory-safe-assembly + assembly { + ptr := add(buffer, add(32, length)) + } + while (true) { + ptr--; + /// @solidity memory-safe-assembly + assembly { + mstore8(ptr, byte(mod(value, 10), _SYMBOLS)) + } + value /= 10; + if (value == 0) break; + } + return buffer; + } + } + + /** + * @dev Converts a `int256` to its ASCII `string` decimal representation. + */ + function toString(int256 value) internal pure returns (string memory) { + return string(abi.encodePacked(value < 0 ? "-" : "", toString(SignedMath.abs(value)))); + } + + /** + * @dev Converts a `uint256` to its ASCII `string` hexadecimal representation. + */ + function toHexString(uint256 value) internal pure returns (string memory) { + unchecked { + return toHexString(value, Math.log256(value) + 1); + } + } + + /** + * @dev Converts a `uint256` to its ASCII `string` hexadecimal representation with fixed length. + */ + function toHexString(uint256 value, uint256 length) internal pure returns (string memory) { + bytes memory buffer = new bytes(2 * length + 2); + buffer[0] = "0"; + buffer[1] = "x"; + for (uint256 i = 2 * length + 1; i > 1; --i) { + buffer[i] = _SYMBOLS[value & 0xf]; + value >>= 4; + } + require(value == 0, "Strings: hex length insufficient"); + return string(buffer); + } + + /** + * @dev Converts an `address` with fixed length of 20 bytes to its not checksummed ASCII `string` hexadecimal representation. + */ + function toHexString(address addr) internal pure returns (string memory) { + return toHexString(uint256(uint160(addr)), _ADDRESS_LENGTH); + } + + /** + * @dev Returns true if the two strings are equal. + */ + function equal(string memory a, string memory b) internal pure returns (bool) { + return keccak256(bytes(a)) == keccak256(bytes(b)); + } +} + +// packages/protocol-rewards/src/lib/ECDSA.sol + +// OpenZeppelin Contracts (last updated v4.9.0) (utils/cryptography/ECDSA.sol) + +/** + * @dev Elliptic Curve Digital Signature Algorithm (ECDSA) operations. + * + * These functions can be used to verify that a message was signed by the holder + * of the private keys of a given address. + */ +library ECDSA { + enum RecoverError { + NoError, + InvalidSignature, + InvalidSignatureLength, + InvalidSignatureS, + InvalidSignatureV // Deprecated in v4.8 + } + + function _throwError(RecoverError error) private pure { + if (error == RecoverError.NoError) { + return; // no error: do nothing + } else if (error == RecoverError.InvalidSignature) { + revert("ECDSA: invalid signature"); + } else if (error == RecoverError.InvalidSignatureLength) { + revert("ECDSA: invalid signature length"); + } else if (error == RecoverError.InvalidSignatureS) { + revert("ECDSA: invalid signature 's' value"); + } + } + + /** + * @dev Returns the address that signed a hashed message (`hash`) with + * `signature` or error string. This address can then be used for verification purposes. + * + * The `ecrecover` EVM opcode allows for malleable (non-unique) signatures: + * this function rejects them by requiring the `s` value to be in the lower + * half order, and the `v` value to be either 27 or 28. + * + * IMPORTANT: `hash` _must_ be the result of a hash operation for the + * verification to be secure: it is possible to craft signatures that + * recover to arbitrary addresses for non-hashed data. A safe way to ensure + * this is by receiving a hash of the original message (which may otherwise + * be too long), and then calling {toEthSignedMessageHash} on it. + * + * Documentation for signature generation: + * - with https://web3js.readthedocs.io/en/v1.3.4/web3-eth-accounts.html#sign[Web3.js] + * - with https://docs.ethers.io/v5/api/signer/#Signer-signMessage[ethers] + * + * _Available since v4.3._ + */ + function tryRecover(bytes32 hash, bytes memory signature) internal pure returns (address, RecoverError) { + if (signature.length == 65) { + bytes32 r; + bytes32 s; + uint8 v; + // ecrecover takes the signature parameters, and the only way to get them + // currently is to use assembly. + /// @solidity memory-safe-assembly + assembly { + r := mload(add(signature, 0x20)) + s := mload(add(signature, 0x40)) + v := byte(0, mload(add(signature, 0x60))) + } + return tryRecover(hash, v, r, s); + } else { + return (address(0), RecoverError.InvalidSignatureLength); + } + } + + /** + * @dev Returns the address that signed a hashed message (`hash`) with + * `signature`. This address can then be used for verification purposes. + * + * The `ecrecover` EVM opcode allows for malleable (non-unique) signatures: + * this function rejects them by requiring the `s` value to be in the lower + * half order, and the `v` value to be either 27 or 28. + * + * IMPORTANT: `hash` _must_ be the result of a hash operation for the + * verification to be secure: it is possible to craft signatures that + * recover to arbitrary addresses for non-hashed data. A safe way to ensure + * this is by receiving a hash of the original message (which may otherwise + * be too long), and then calling {toEthSignedMessageHash} on it. + */ + function recover(bytes32 hash, bytes memory signature) internal pure returns (address) { + (address recovered, RecoverError error) = tryRecover(hash, signature); + _throwError(error); + return recovered; + } + + /** + * @dev Overload of {ECDSA-tryRecover} that receives the `r` and `vs` short-signature fields separately. + * + * See https://eips.ethereum.org/EIPS/eip-2098[EIP-2098 short signatures] + * + * _Available since v4.3._ + */ + function tryRecover(bytes32 hash, bytes32 r, bytes32 vs) internal pure returns (address, RecoverError) { + bytes32 s = vs & bytes32(0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff); + uint8 v = uint8((uint256(vs) >> 255) + 27); + return tryRecover(hash, v, r, s); + } + + /** + * @dev Overload of {ECDSA-recover} that receives the `r and `vs` short-signature fields separately. + * + * _Available since v4.2._ + */ + function recover(bytes32 hash, bytes32 r, bytes32 vs) internal pure returns (address) { + (address recovered, RecoverError error) = tryRecover(hash, r, vs); + _throwError(error); + return recovered; + } + + /** + * @dev Overload of {ECDSA-tryRecover} that receives the `v`, + * `r` and `s` signature fields separately. + * + * _Available since v4.3._ + */ + function tryRecover(bytes32 hash, uint8 v, bytes32 r, bytes32 s) internal pure returns (address, RecoverError) { + // EIP-2 still allows signature malleability for ecrecover(). Remove this possibility and make the signature + // unique. Appendix F in the Ethereum Yellow paper (https://ethereum.github.io/yellowpaper/paper.pdf), defines + // the valid range for s in (301): 0 < s < secp256k1n ÷ 2 + 1, and for v in (302): v ∈ {27, 28}. Most + // signatures from current libraries generate a unique signature with an s-value in the lower half order. + // + // If your library generates malleable signatures, such as s-values in the upper range, calculate a new s-value + // with 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 - s1 and flip v from 27 to 28 or + // vice versa. If your library also generates signatures with 0/1 for v instead 27/28, add 27 to v to accept + // these malleable signatures as well. + if (uint256(s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) { + return (address(0), RecoverError.InvalidSignatureS); + } + + // If the signature is valid (and not malleable), return the signer address + address signer = ecrecover(hash, v, r, s); + if (signer == address(0)) { + return (address(0), RecoverError.InvalidSignature); + } + + return (signer, RecoverError.NoError); + } + + /** + * @dev Overload of {ECDSA-recover} that receives the `v`, + * `r` and `s` signature fields separately. + */ + function recover(bytes32 hash, uint8 v, bytes32 r, bytes32 s) internal pure returns (address) { + (address recovered, RecoverError error) = tryRecover(hash, v, r, s); + _throwError(error); + return recovered; + } + + /** + * @dev Returns an Ethereum Signed Message, created from a `hash`. This + * produces hash corresponding to the one signed with the + * https://eth.wiki/json-rpc/API#eth_sign[`eth_sign`] + * JSON-RPC method as part of EIP-191. + * + * See {recover}. + */ + function toEthSignedMessageHash(bytes32 hash) internal pure returns (bytes32 message) { + // 32 is the length in bytes of hash, + // enforced by the type signature above + /// @solidity memory-safe-assembly + assembly { + mstore(0x00, "\x19Ethereum Signed Message:\n32") + mstore(0x1c, hash) + message := keccak256(0x00, 0x3c) + } + } + + /** + * @dev Returns an Ethereum Signed Message, created from `s`. This + * produces hash corresponding to the one signed with the + * https://eth.wiki/json-rpc/API#eth_sign[`eth_sign`] + * JSON-RPC method as part of EIP-191. + * + * See {recover}. + */ + function toEthSignedMessageHash(bytes memory s) internal pure returns (bytes32) { + return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n", Strings.toString(s.length), s)); + } + + /** + * @dev Returns an Ethereum Signed Typed Data, created from a + * `domainSeparator` and a `structHash`. This produces hash corresponding + * to the one signed with the + * https://eips.ethereum.org/EIPS/eip-712[`eth_signTypedData`] + * JSON-RPC method as part of EIP-712. + * + * See {recover}. + */ + function toTypedDataHash(bytes32 domainSeparator, bytes32 structHash) internal pure returns (bytes32 data) { + /// @solidity memory-safe-assembly + assembly { + let ptr := mload(0x40) + mstore(ptr, "\x19\x01") + mstore(add(ptr, 0x02), domainSeparator) + mstore(add(ptr, 0x22), structHash) + data := keccak256(ptr, 0x42) + } + } + + /** + * @dev Returns an Ethereum Signed Data with intended validator, created from a + * `validator` and `data` according to the version 0 of EIP-191. + * + * See {recover}. + */ + function toDataWithIntendedValidatorHash(address validator, bytes memory data) internal pure returns (bytes32) { + return keccak256(abi.encodePacked("\x19\x00", validator, data)); + } +} + +// packages/protocol-rewards/src/lib/EIP712.sol + +// OpenZeppelin Contracts (last updated v4.9.0) (utils/cryptography/EIP712.sol) + +/** + * @dev https://eips.ethereum.org/EIPS/eip-712[EIP 712] is a standard for hashing and signing of typed structured data. + * + * The encoding specified in the EIP is very generic, and such a generic implementation in Solidity is not feasible, + * thus this contract does not implement the encoding itself. Protocols need to implement the type-specific encoding + * they need in their contracts using a combination of `abi.encode` and `keccak256`. + * + * This contract implements the EIP 712 domain separator ({_domainSeparatorV4}) that is used as part of the encoding + * scheme, and the final step of the encoding to obtain the message digest that is then signed via ECDSA + * ({_hashTypedDataV4}). + * + * The implementation of the domain separator was designed to be as efficient as possible while still properly updating + * the chain id to protect against replay attacks on an eventual fork of the chain. + * + * NOTE: This contract implements the version of the encoding known as "v4", as implemented by the JSON RPC method + * https://docs.metamask.io/guide/signing-data.html[`eth_signTypedDataV4` in MetaMask]. + * + * NOTE: In the upgradeable version of this contract, the cached values will correspond to the address, and the domain + * separator of the implementation contract. This will cause the `_domainSeparatorV4` function to always rebuild the + * separator from the immutable values, which is cheaper than accessing a cached version in cold storage. + * + * _Available since v3.4._ + * + * @custom:oz-upgrades-unsafe-allow state-variable-immutable state-variable-assignment + */ +abstract contract EIP712 is IERC5267 { + using ShortStrings for *; + + bytes32 private constant _TYPE_HASH = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); + + // Cache the domain separator as an immutable value, but also store the chain id that it corresponds to, in order to + // invalidate the cached domain separator if the chain id changes. + bytes32 private immutable _cachedDomainSeparator; + uint256 private immutable _cachedChainId; + address private immutable _cachedThis; + + bytes32 private immutable _hashedName; + bytes32 private immutable _hashedVersion; + + ShortString private immutable _name; + ShortString private immutable _version; + string private _nameFallback; + string private _versionFallback; + + /** + * @dev Initializes the domain separator and parameter caches. + * + * The meaning of `name` and `version` is specified in + * https://eips.ethereum.org/EIPS/eip-712#definition-of-domainseparator[EIP 712]: + * + * - `name`: the user readable name of the signing domain, i.e. the name of the DApp or the protocol. + * - `version`: the current major version of the signing domain. + * + * NOTE: These parameters cannot be changed except through a xref:learn::upgrading-smart-contracts.adoc[smart + * contract upgrade]. + */ + constructor(string memory name, string memory version) { + _name = name.toShortStringWithFallback(_nameFallback); + _version = version.toShortStringWithFallback(_versionFallback); + _hashedName = keccak256(bytes(name)); + _hashedVersion = keccak256(bytes(version)); + + _cachedChainId = block.chainid; + _cachedDomainSeparator = _buildDomainSeparator(); + _cachedThis = address(this); + } + + /** + * @dev Returns the domain separator for the current chain. + */ + function _domainSeparatorV4() internal view returns (bytes32) { + if (address(this) == _cachedThis && block.chainid == _cachedChainId) { + return _cachedDomainSeparator; + } else { + return _buildDomainSeparator(); + } + } + + function _buildDomainSeparator() private view returns (bytes32) { + return keccak256(abi.encode(_TYPE_HASH, _hashedName, _hashedVersion, block.chainid, address(this))); + } + + /** + * @dev Given an already https://eips.ethereum.org/EIPS/eip-712#definition-of-hashstruct[hashed struct], this + * function returns the hash of the fully encoded EIP712 message for this domain. + * + * This hash can be used together with {ECDSA-recover} to obtain the signer of a message. For example: + * + * ```solidity + * bytes32 digest = _hashTypedDataV4(keccak256(abi.encode( + * keccak256("Mail(address to,string contents)"), + * mailTo, + * keccak256(bytes(mailContents)) + * ))); + * address signer = ECDSA.recover(digest, signature); + * ``` + */ + function _hashTypedDataV4(bytes32 structHash) internal view virtual returns (bytes32) { + return ECDSA.toTypedDataHash(_domainSeparatorV4(), structHash); + } + + /** + * @dev See {EIP-5267}. + * + * _Available since v4.9._ + */ + function eip712Domain() + public + view + virtual + override + returns ( + bytes1 fields, + string memory name, + string memory version, + uint256 chainId, + address verifyingContract, + bytes32 salt, + uint256[] memory extensions + ) + { + return ( + hex"0f", // 01111 + _name.toStringWithFallback(_nameFallback), + _version.toStringWithFallback(_versionFallback), + block.chainid, + address(this), + bytes32(0), + new uint256[](0) + ); + } +} + +// packages/protocol-rewards/src/ProtocolRewards.sol + +/// @title ProtocolRewards +/// @notice Manager of deposits & withdrawals for protocol rewards +contract ProtocolRewards is Enjoy, IProtocolRewards, EIP712 { + /// @notice The EIP-712 typehash for gasless withdraws + bytes32 public constant WITHDRAW_TYPEHASH = keccak256("Withdraw(address from,address to,uint256 amount,uint256 nonce,uint256 deadline)"); + + /// @notice An account's balance + mapping(address => uint256) public balanceOf; + + /// @notice An account's nonce for gasless withdraws + mapping(address => uint256) public nonces; + + constructor() payable EIP712("ProtocolRewards", "1") {} + + /// @notice The total amount of ETH held in the contract + function totalSupply() external view returns (uint256) { + return address(this).balance; + } + + /// @notice Generic function to deposit ETH for a recipient, with an optional comment + /// @param to Address to deposit to + /// @param to Reason system reason for deposit (used for indexing) + /// @param comment Optional comment as reason for deposit + function deposit(address to, bytes4 reason, string calldata comment) external payable { + if (to == address(0)) { + revert ADDRESS_ZERO(); + } + + balanceOf[to] += msg.value; + + emit Deposit(msg.sender, to, reason, msg.value, comment); + } + + /// @notice Generic function to deposit ETH for multiple recipients, with an optional comment + /// @param recipients recipients to send the amount to, array aligns with amounts + /// @param amounts amounts to send to each recipient, array aligns with recipients + /// @param reasons optional bytes4 hash for indexing + /// @param comment Optional comment to include with mint + function depositBatch(address[] calldata recipients, uint256[] calldata amounts, bytes4[] calldata reasons, string calldata comment) external payable { + uint256 numRecipients = recipients.length; + + if (numRecipients != amounts.length || numRecipients != reasons.length) { + revert ARRAY_LENGTH_MISMATCH(); + } + + uint256 expectedTotalValue; + + for (uint256 i; i < numRecipients; ) { + expectedTotalValue += amounts[i]; + + unchecked { + ++i; + } + } + + if (msg.value != expectedTotalValue) { + revert INVALID_DEPOSIT(); + } + + address currentRecipient; + uint256 currentAmount; + + for (uint256 i; i < numRecipients; ) { + currentRecipient = recipients[i]; + currentAmount = amounts[i]; + + if (currentRecipient == address(0)) { + revert ADDRESS_ZERO(); + } + + balanceOf[currentRecipient] += currentAmount; + + emit Deposit(msg.sender, currentRecipient, reasons[i], currentAmount, comment); + + unchecked { + ++i; + } + } + } + + /// @notice Used by Zora ERC-721 & ERC-1155 contracts to deposit protocol rewards + /// @param creator Creator for NFT rewards + /// @param creatorReward Creator reward amount + /// @param createReferral Creator referral + /// @param createReferralReward Creator referral reward + /// @param mintReferral Mint referral user + /// @param mintReferralReward Mint referral amount + /// @param firstMinter First minter reward + /// @param firstMinterReward First minter reward amount + /// @param zora ZORA recipient + /// @param zoraReward ZORA amount + function depositRewards( + address creator, + uint256 creatorReward, + address createReferral, + uint256 createReferralReward, + address mintReferral, + uint256 mintReferralReward, + address firstMinter, + uint256 firstMinterReward, + address zora, + uint256 zoraReward + ) external payable { + if (msg.value != (creatorReward + createReferralReward + mintReferralReward + firstMinterReward + zoraReward)) { + revert INVALID_DEPOSIT(); + } + + unchecked { + if (creator != address(0)) { + balanceOf[creator] += creatorReward; + } + if (createReferral != address(0)) { + balanceOf[createReferral] += createReferralReward; + } + if (mintReferral != address(0)) { + balanceOf[mintReferral] += mintReferralReward; + } + if (firstMinter != address(0)) { + balanceOf[firstMinter] += firstMinterReward; + } + if (zora != address(0)) { + balanceOf[zora] += zoraReward; + } + } + + emit RewardsDeposit( + creator, + createReferral, + mintReferral, + firstMinter, + zora, + msg.sender, + creatorReward, + createReferralReward, + mintReferralReward, + firstMinterReward, + zoraReward + ); + } + + /// @notice Withdraw protocol rewards + /// @param to Withdraws from msg.sender to this address + /// @param amount Amount to withdraw (0 for total balance) + function withdraw(address to, uint256 amount) external { + if (to == address(0)) { + revert ADDRESS_ZERO(); + } + + address owner = msg.sender; + + if (amount > balanceOf[owner]) { + revert INVALID_WITHDRAW(); + } + + if (amount == 0) { + amount = balanceOf[owner]; + } + + balanceOf[owner] -= amount; + + emit Withdraw(owner, to, amount); + + (bool success, ) = to.call{value: amount}(""); + + if (!success) { + revert TRANSFER_FAILED(); + } + } + + /// @notice Withdraw rewards on behalf of an address + /// @param to The address to withdraw for + /// @param amount The amount to withdraw (0 for total balance) + function withdrawFor(address to, uint256 amount) external { + if (to == address(0)) { + revert ADDRESS_ZERO(); + } + + if (amount > balanceOf[to]) { + revert INVALID_WITHDRAW(); + } + + if (amount == 0) { + amount = balanceOf[to]; + } + + balanceOf[to] -= amount; + + emit Withdraw(to, to, amount); + + (bool success, ) = to.call{value: amount}(""); + + if (!success) { + revert TRANSFER_FAILED(); + } + } + + /// @notice Execute a withdraw of protocol rewards via signature + /// @param from Withdraw from this address + /// @param to Withdraw to this address + /// @param amount Amount to withdraw (0 for total balance) + /// @param deadline Deadline for the signature to be valid + /// @param v V component of signature + /// @param r R component of signature + /// @param s S component of signature + function withdrawWithSig(address from, address to, uint256 amount, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external { + if (block.timestamp > deadline) { + revert SIGNATURE_DEADLINE_EXPIRED(); + } + + bytes32 withdrawHash; + + unchecked { + withdrawHash = keccak256(abi.encode(WITHDRAW_TYPEHASH, from, to, amount, nonces[from]++, deadline)); + } + + bytes32 digest = _hashTypedDataV4(withdrawHash); + + address recoveredAddress = ecrecover(digest, v, r, s); + + if (recoveredAddress == address(0) || recoveredAddress != from) { + revert INVALID_SIGNATURE(); + } + + if (to == address(0)) { + revert ADDRESS_ZERO(); + } + + if (amount > balanceOf[from]) { + revert INVALID_WITHDRAW(); + } + + if (amount == 0) { + amount = balanceOf[from]; + } + + balanceOf[from] -= amount; + + emit Withdraw(from, to, amount); + + (bool success, ) = to.call{value: amount}(""); + + if (!success) { + revert TRANSFER_FAILED(); + } + } +} diff --git a/packages/comments/tsconfig.build.json b/packages/comments/tsconfig.build.json new file mode 100644 index 000000000..927bd0966 --- /dev/null +++ b/packages/comments/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "@zoralabs/tsconfig/tsconfig.json", + "compilerOptions": { + "lib": ["es2021", "DOM"], + "baseUrl": ".", + "outDir": "dist" + }, + "exclude": ["node_modules/**", "dist/**"], + "include": ["package/**/*.ts"] +} diff --git a/packages/comments/tsconfig.json b/packages/comments/tsconfig.json new file mode 100644 index 000000000..703a0ac3b --- /dev/null +++ b/packages/comments/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@zoralabs/tsconfig/tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "outDir": "dist" + }, + "exclude": ["node_modules/**", "dist/**"], + "include": ["package/**/*.ts", "script/*.ts"] +} diff --git a/packages/comments/tsup.config.ts b/packages/comments/tsup.config.ts new file mode 100644 index 000000000..dfdb61917 --- /dev/null +++ b/packages/comments/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["package/index.ts"], + sourcemap: true, + clean: true, + dts: false, + format: ["cjs", "esm"], + onSuccess: + "tsc --project tsconfig.build.json --emitDeclarationOnly --declaration --declarationMap", +}); diff --git a/packages/comments/wagmi.config.ts b/packages/comments/wagmi.config.ts new file mode 100644 index 000000000..e0f5ada6d --- /dev/null +++ b/packages/comments/wagmi.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "@wagmi/cli"; +import { foundry } from "@wagmi/cli/plugins"; + +export default defineConfig({ + out: "package/wagmiGenerated.ts", + plugins: [ + foundry({ + forge: { + build: false, + }, + include: ["CommentsImpl", "CallerAndCommenterImpl"].map( + (contractName) => `${contractName}.json`, + ), + }), + ], +}); diff --git a/packages/creator-subgraph/CHANGELOG.md b/packages/creator-subgraph/CHANGELOG.md index 7aa2d4812..8520b9a21 100644 --- a/packages/creator-subgraph/CHANGELOG.md +++ b/packages/creator-subgraph/CHANGELOG.md @@ -1,5 +1,17 @@ # @zoralabs/nft-creator-subgraph +## 0.3.20 + +### Patch Changes + +- Updated dependencies [4928687d] +- Updated dependencies [ffc695d3] + - @zoralabs/protocol-deployments@0.3.9 + - @zoralabs/protocol-rewards@1.2.5 + - @zoralabs/zora-1155-contracts@2.13.2 + - @zoralabs/comments-contracts@0.0.1 + - @zoralabs/erc20z@2.1.1 + ## 0.3.19 ### Patch Changes diff --git a/packages/creator-subgraph/config/zora-mainnet.yaml b/packages/creator-subgraph/config/zora-mainnet.yaml index dead683ec..1538b55b1 100644 --- a/packages/creator-subgraph/config/zora-mainnet.yaml +++ b/packages/creator-subgraph/config/zora-mainnet.yaml @@ -1,8 +1,8 @@ chainid: "7777777" network: zora-mainnet grafting: - base: "QmS4F1XH8wztuiUXkFRCHu1drZrdFXpeGTUmQFyjU4Y4Y7" - block: "18750633" + base: "QmWZoNnJJQdzznpZcvFZvUMMJGq6zuD1tv5ovSfitNTyHT" + block: "21296000" factories1155: - address: "0x35ca784918bf11692708c1D530691704AAcEA95E" startBlock: "45420" @@ -49,3 +49,7 @@ zoraTimedSaleStrategy: - address: "0x777777722D078c97c6ad07d9f36801e653E356Ae" startBlock: "17964312" version: "1" +comments: + - address: "0x7777777C2B3132e03a65721a41745C07170a5877" + startBlock: "21297000" + version: "1" diff --git a/packages/creator-subgraph/config/zora-sepolia.yaml b/packages/creator-subgraph/config/zora-sepolia.yaml index fb9c0e958..0b381f054 100644 --- a/packages/creator-subgraph/config/zora-sepolia.yaml +++ b/packages/creator-subgraph/config/zora-sepolia.yaml @@ -1,8 +1,8 @@ chainid: "999999999" network: zora-sepolia grafting: - base: "Qma25UyHsDGAKHLAaVRgvW5zBAHMEuE6KSHYLWSLPuYeNi" - block: "13055000" + base: "QmcfePLEF39p1ZwULU3ckR2Xjrx4hqxrxJ3DkJerLUV2bH" + block: "14990000" factories1155: - address: "0x777777C338d93e2C7adf08D102d45CA7CC4Ed021" startBlock: "309965" @@ -49,3 +49,7 @@ zoraTimedSaleStrategy: - address: "0x777777722D078c97c6ad07d9f36801e653E356Ae" startBlock: "12271178" version: "2" +comments: + - address: "0x7777777C2B3132e03a65721a41745C07170a5877" + startBlock: "14999570" + version: "1" diff --git a/packages/creator-subgraph/package.json b/packages/creator-subgraph/package.json index 87245a625..1e50df266 100644 --- a/packages/creator-subgraph/package.json +++ b/packages/creator-subgraph/package.json @@ -1,6 +1,6 @@ { "name": "@zoralabs/nft-creator-subgraph", - "version": "0.3.19", + "version": "0.3.20", "license": "MIT", "repository": "https://github.com/ourzora/zora-creator-subgraph", "private": true, @@ -26,7 +26,8 @@ "@zoralabs/protocol-deployments": "workspace:^", "@zoralabs/protocol-rewards": "workspace:^", "@zoralabs/zora-1155-contracts": "workspace:^", - "@zoralabs/erc20z": "workspace:^" + "@zoralabs/erc20z": "workspace:^", + "@zoralabs/comments-contracts": "workspace:^" }, "devDependencies": { "@goldskycom/cli": "^7.1.0", diff --git a/packages/creator-subgraph/schema.graphql b/packages/creator-subgraph/schema.graphql index 0542d6c57..5d2e722a8 100644 --- a/packages/creator-subgraph/schema.graphql +++ b/packages/creator-subgraph/schema.graphql @@ -727,3 +727,26 @@ type ZoraTimedSaleStrategyRewardsDeposit @entity { zora: Bytes! zoraReward: BigInt! } + +type Comment @entity { + # Begin – default data block + id: ID! + txn: TransactionInfo! + address: Bytes! + block: BigInt! + timestamp: BigInt! + # End – default data block + + tokenAndContract: ZoraCreateToken! + commentText: String! + tokenId: BigInt! + nonce: Bytes! + commenter: Bytes! + contractAddress: Bytes! + replyToId: Bytes! + referrer: Bytes! + commentTimestamp: BigInt! + sparksQuantity: BigInt! + commentId: Bytes! + replyCount: BigInt! +} diff --git a/packages/creator-subgraph/scripts/extract-abis.mjs b/packages/creator-subgraph/scripts/extract-abis.mjs index b51cfa28e..202e80de7 100644 --- a/packages/creator-subgraph/scripts/extract-abis.mjs +++ b/packages/creator-subgraph/scripts/extract-abis.mjs @@ -22,6 +22,7 @@ import erc721Drop from "@zoralabs/nft-drop-contracts/dist/artifacts/ERC721Drop.s import zoraNFTCreatorV1 from "@zoralabs/nft-drop-contracts/dist/artifacts/ZoraNFTCreatorV1.sol/ZoraNFTCreatorV1.json" assert { type: "json" }; import editionMetadataRenderer from "@zoralabs/nft-drop-contracts/dist/artifacts/EditionMetadataRenderer.sol/EditionMetadataRenderer.json" assert { type: "json" }; import dropMetadataRenderer from "@zoralabs/nft-drop-contracts/dist/artifacts/DropMetadataRenderer.sol/DropMetadataRenderer.json" assert { type: "json" }; +import { commentsImplABI } from "@zoralabs/comments-contracts"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -70,3 +71,5 @@ output_abi("ZoraMintsManagerImpl", zoraMintsManagerImplABI); output_abi("ZoraSparks1155", zoraSparks1155ABI); output_abi("ERC20Minter", erc20MinterABI); + +output_abi("Comments", commentsImplABI); diff --git a/packages/creator-subgraph/src/commentsMappings/templates/commentHandlers.ts b/packages/creator-subgraph/src/commentsMappings/templates/commentHandlers.ts new file mode 100644 index 000000000..daba03f47 --- /dev/null +++ b/packages/creator-subgraph/src/commentsMappings/templates/commentHandlers.ts @@ -0,0 +1,126 @@ +import { BigInt, Bytes } from "@graphprotocol/graph-ts"; +import { + BackfilledComment, + Commented, + SparkedComment, +} from "../../../generated/Comments/Comments"; +import { Comment } from "../../../generated/schema"; +import { getTokenId } from "../../common/getTokenId"; +import { makeTransaction } from "../../common/makeTransaction"; + +export const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; + +export function getCommentId(id: Bytes): string { + return `${id.toHex()}`; +} + +export function setComment( + comment: Comment, + text: string, + tokenId: BigInt, + nonce: Bytes, + commenter: Bytes, + contractAddress: Bytes, + replyToId: Bytes, + referrer: Bytes, + timestamp: BigInt, + sparksQuantity: BigInt, + commentId: Bytes, +): void { + comment.commentText = text; + comment.tokenId = tokenId; + comment.nonce = nonce; + comment.commenter = commenter; + comment.contractAddress = contractAddress; + comment.replyToId = replyToId; + comment.referrer = referrer; + comment.commentTimestamp = timestamp; + comment.sparksQuantity = sparksQuantity; + comment.commentId = commentId; + comment.replyCount = BigInt.fromI32(0); + + const parentComment = Comment.load(getCommentId(replyToId)); + if (parentComment != null) { + parentComment.replyCount = parentComment.replyCount.plus(BigInt.fromI32(1)); + parentComment.save(); + } + + comment.save(); +} + +export function handleCommented(event: Commented): void { + const comment = new Comment(getCommentId(event.params.commentId)); + const tokenAndContract = getTokenId( + event.params.commentIdentifier.contractAddress, + event.params.commentIdentifier.tokenId, + ); + comment.tokenAndContract = tokenAndContract; + + comment.txn = makeTransaction(event); + comment.block = event.block.number; + comment.timestamp = event.block.timestamp; + comment.address = event.address; + + setComment( + comment, + event.params.text, + event.params.commentIdentifier.tokenId, + event.params.commentIdentifier.nonce, + event.params.commentIdentifier.commenter, + event.params.commentIdentifier.contractAddress, + event.params.replyToId, + event.params.referrer, + event.params.timestamp, + BigInt.fromI32(0), + event.params.commentId, + ); + + comment.save(); +} + +export function handleSparkedComment(event: SparkedComment): void { + const comment = Comment.load(getCommentId(event.params.commentId)); + if (comment == null) { + return; + } + comment.sparksQuantity = comment.sparksQuantity.plus( + event.params.sparksQuantity, + ); + comment.save(); +} + +export function handleBackfilledComment(event: BackfilledComment): void { + var comment = Comment.load(getCommentId(event.params.commentId)); + + if (comment != null) { + return; + } + + comment = new Comment(getCommentId(event.params.commentId)); + const tokenAndContract = getTokenId( + event.params.commentIdentifier.contractAddress, + event.params.commentIdentifier.tokenId, + ); + comment.tokenAndContract = tokenAndContract; + + comment.txn = makeTransaction(event); + comment.block = event.block.number; + comment.timestamp = event.block.timestamp; + comment.address = event.address; + + setComment( + comment, + event.params.text, + event.params.commentIdentifier.tokenId, + event.params.commentIdentifier.nonce, + event.params.commentIdentifier.commenter, + event.params.commentIdentifier.contractAddress, + new Bytes(0), + Bytes.fromHexString(ZERO_ADDRESS), + event.params.timestamp, + BigInt.fromI32(0), + event.params.commentId, + ); + + comment.save(); +} diff --git a/packages/creator-subgraph/subgraph.template.yaml b/packages/creator-subgraph/subgraph.template.yaml index 4e341f98c..4e94db8e7 100644 --- a/packages/creator-subgraph/subgraph.template.yaml +++ b/packages/creator-subgraph/subgraph.template.yaml @@ -281,6 +281,32 @@ dataSources: - event: SaleSetV2(indexed address,indexed uint256,(uint64,uint64,uint64,bool,uint256,address,address,string,string),uint256) handler: handleZoraTimedSaleStrategySaleSetV2 {{/zoraTimedSaleStrategy}} +{{#comments}} + - name: Comments + kind: ethereum/contract + network: {{network}} + source: + abi: Comments + address: "{{address}}" + startBlock: {{startBlock}} + mapping: + kind: ethereum/events + apiVersion: 0.0.7 + entities: + - Comment + language: wasm/assemblyscript + file: ./src/commentsMappings/templates/commentHandlers.ts + abis: + - name: Comments + file: ./abis/Comments.json + eventHandlers: + - event: Commented(indexed bytes32,(address,address,uint256,bytes32),bytes32,(address,address,uint256,bytes32),uint256,string,uint256,address) + handler: handleCommented + - event: SparkedComment(indexed bytes32,(address,address,uint256,bytes32),uint256,address,uint256,address) + handler: handleSparkedComment + - event: BackfilledComment(indexed bytes32,(address,address,uint256,bytes32),string,uint256,bytes32) + handler: handleBackfilledComment +{{/comments}} templates: - name: MetadataInfo kind: file/ipfs diff --git a/packages/protocol-deployments-gen/package.json b/packages/protocol-deployments-gen/package.json index a2bea5d87..3510602f7 100644 --- a/packages/protocol-deployments-gen/package.json +++ b/packages/protocol-deployments-gen/package.json @@ -13,6 +13,7 @@ "@zoralabs/1155-deployments": "workspace:^", "@zoralabs/zora-1155-contracts": "workspace:^", "@zoralabs/sparks-contracts": "workspace:^", + "@zoralabs/comments-contracts": "workspace:^", "@zoralabs/erc20z": "workspace:^" }, "devDependencies": { diff --git a/packages/protocol-deployments-gen/wagmi.config.ts b/packages/protocol-deployments-gen/wagmi.config.ts index b5e22251f..e6274bee2 100644 --- a/packages/protocol-deployments-gen/wagmi.config.ts +++ b/packages/protocol-deployments-gen/wagmi.config.ts @@ -19,6 +19,10 @@ import { secondarySwapABI, iwethABI, } from "@zoralabs/erc20z"; +import { + commentsImplABI, + callerAndCommenterImplABI, +} from "@zoralabs/comments-contracts"; import { iPremintDefinitionsABI } from "@zoralabs/zora-1155-contracts"; import { zora } from "viem/chains"; @@ -47,6 +51,10 @@ type AbiAndAddresses = { address: Record; }; +const extractErrors = (abi: Abi) => { + return abi.filter((x) => x.type === "error"); +}; + const addAddress = < T extends Record, K extends Record, @@ -354,12 +362,54 @@ const getErc20zContracts = (): ContractConfig[] => { ]; }; +const getCommentsContracts = (): ContractConfig[] => { + const addresses: Addresses = {}; + + const addressesFiles = readdirSync("../comments/addresses"); + + const storedConfigs = addressesFiles.map((file) => { + return { + chainId: parseInt(file.split(".")[0]), + config: JSON.parse( + readFileSync(`../comments/addresses/${file}`, "utf-8"), + ) as { + COMMENTS: Address; + CALLER_AND_COMMENTER: Address; + }, + }; + }); + + addAddress({ + abi: commentsImplABI, + addresses, + configKey: "COMMENTS", + contractName: "Comments", + storedConfigs, + }); + + addAddress({ + abi: [ + ...callerAndCommenterImplABI, + ...extractErrors(zoraTimedSaleStrategyImplABI), + ...extractErrors(abis.zoraCreator1155ImplABI), + ...extractErrors(commentsImplABI), + ], + addresses, + configKey: "CALLER_AND_COMMENTER", + contractName: "CallerAndCommenter", + storedConfigs, + }); + + return toConfig(addresses); +}; + export default defineConfig({ out: "./generated/wagmi.ts", contracts: [ ...get1155Contracts(), ...getErc20zContracts(), ...getSharedAddresses(), + ...getCommentsContracts(), ...getSparksAddresses(), { abi: iPremintDefinitionsABI, diff --git a/packages/protocol-deployments/CHANGELOG.md b/packages/protocol-deployments/CHANGELOG.md index 910fe15c3..b5444fddc 100644 --- a/packages/protocol-deployments/CHANGELOG.md +++ b/packages/protocol-deployments/CHANGELOG.md @@ -1,5 +1,17 @@ # @zoralabs/protocol-deployments +## 0.3.9 + +### Patch Changes + +- 4928687d: - Include the `Comments` and `CallerAndCommenter` abis and deployed addresses. + - Added new exports for Comments contract cross-chain functionality: + - Introduced `permitCommentTypedDataDefinition` function to generate typed data for cross-chain permit commenting + - Introduced `permitSparkCommentTypedDataDefinition` function to generate typed data for cross-chain permit sparking + - Introduced `permitTimedSaleMintAndCommentTypedDataType` to generate typed data for cross-chain permit minting and commenting. + - Introduced `permitBuyOnSecondaryAndCommentTypedDataDefinition` function to generate typed data for cross-chain permit buying on secondary and commenting. + - Added `sparkValue` helper function to get the value of a Spark + ## 0.3.8 ### Patch Changes diff --git a/packages/protocol-deployments/package.json b/packages/protocol-deployments/package.json index 74948e6bc..59085b663 100644 --- a/packages/protocol-deployments/package.json +++ b/packages/protocol-deployments/package.json @@ -1,6 +1,6 @@ { "name": "@zoralabs/protocol-deployments", - "version": "0.3.8", + "version": "0.3.9", "repository": "https://github.com/ourzora/zora-protocol", "license": "MIT", "type": "module", diff --git a/packages/protocol-deployments/src/typedData.ts b/packages/protocol-deployments/src/typedData.ts index 01e25b98b..445ef4ec0 100644 --- a/packages/protocol-deployments/src/typedData.ts +++ b/packages/protocol-deployments/src/typedData.ts @@ -11,11 +11,14 @@ import { getAbiItem, keccak256, toHex, + parseEther, } from "viem"; import { zoraMints1155Address, iPremintDefinitionsABI, sponsoredSparksSpenderAddress, + commentsAddress, + callerAndCommenterAddress, } from "./generated/wagmi"; import { PremintConfigEncoded, @@ -27,6 +30,10 @@ import { TokenCreationConfigV1, TokenCreationConfigV2, TokenCreationConfigV3, + PermitComment, + PermitSparkComment, + PermitMintAndComment, + PermitBuyOnSecondaryAndComment, } from "./types"; const premintTypedDataDomain = ({ @@ -413,3 +420,258 @@ export const sponsoredSparksBatchTypedDataDefinition = ({ verifyingContract: sponsoredSparksSpenderAddress[chainId], }, }); + +const commentIdentifierType = [ + { name: "contractAddress", type: "address" }, + { name: "tokenId", type: "uint256" }, + { name: "commenter", type: "address" }, + { name: "nonce", type: "bytes32" }, +] as const; + +const commentsDomain = ({ + signingChainId, + destinationChainId, +}: { + signingChainId: number; + destinationChainId: keyof typeof commentsAddress; +}): TypedDataDomain => ({ + chainId: signingChainId, + name: "Comments", + version: "1", + verifyingContract: commentsAddress[destinationChainId]!, +}); + +/** + * Generates the typed data definition for a permit comment, for cross-chain commenting. + * + * The permit allows a user to sign a comment message on one chain, which can then be + * submitted by anyone on the destination chain to execute the comment action. + * + * The permit includes details such as the comment text, the commenter's address, + * the comment being replied to, and chain IDs for the source and destination chains. + * + * The typed data is generated in a way that makes the signature happen on the source chain + * but be valid to be executed on the destination chain. + * + * @param message - The {@link PermitComment} containing the details of the comment permit. + * @param signingAccount - (optional) The account that is signing the message, if different thatn the commentor. + * Only needed if the commentor is a smart wallet; in this case the signing account should be an account + * that is one of the smart wallet owners. + * @returns A {@link TypedDataDefinition} object compatible with EIP-712 for structured data hashing and signing, + * including types, message, primary type, domain, and the signer's account address, which is + * the commenter's address. + */ +export const permitCommentTypedDataDefinition = ( + message: PermitComment, + signingAccount?: Address, +): TypedDataDefinition & { + account: Address; +} => { + const permitCommentTypedDataType = { + PermitComment: [ + { name: "contractAddress", type: "address" }, + { name: "tokenId", type: "uint256" }, + { name: "commenter", type: "address" }, + { name: "replyTo", type: "CommentIdentifier" }, + { name: "text", type: "string" }, + { name: "deadline", type: "uint256" }, + { name: "nonce", type: "bytes32" }, + { name: "commenterSmartWallet", type: "address" }, + { name: "referrer", type: "address" }, + { name: "sourceChainId", type: "uint32" }, + { name: "destinationChainId", type: "uint32" }, + ], + CommentIdentifier: commentIdentifierType, + } as const; + return { + types: permitCommentTypedDataType, + message, + primaryType: "PermitComment", + domain: commentsDomain({ + signingChainId: message.sourceChainId, + destinationChainId: + message.destinationChainId as keyof typeof commentsAddress, + }), + account: signingAccount || message.commenter, + }; +}; + +/** + * Generates the typed data definition for a permit spark comment, for cross-chain sparking (liking with value) of comments. + * + * The permit allows a user to sign a spark comment message on one chain, which can then be + * submitted by anyone on the destination chain to execute the spark action. + * + * The permit includes details such as the comment to be sparked, the sparker's address, + * the quantity of sparks, and the source and destination chain ids. + * + * The typed data is generated in a way that makes the signature happen on the source chain + * but be valid to be executed on the destination chain. + * + * @param message - The {@link PermitSparkComment} containing the details of the spark comment permit. + * @param signingAccount - (optional) The account that is signing the message, if different than the commenter. + * Only needed if the commenter is a smart wallet; in this case the signing account should be an account + * that is one of the smart wallet owners. + * @returns A {@link TypedDataDefinition} object compatible with EIP-712 for structured data hashing and signing, + * including types, message, primary type, domain, and the signer's account address, which is + * the sparker's address. + */ +export const permitSparkCommentTypedDataDefinition = ( + message: PermitSparkComment, + signingAccount?: Address, +): TypedDataDefinition< + typeof permitSparkCommentTypedDataType, + "PermitSparkComment" +> & { account: Address } => { + const permitSparkCommentTypedDataType = { + PermitSparkComment: [ + { name: "comment", type: "CommentIdentifier" }, + { name: "sparker", type: "address" }, + { name: "sparksQuantity", type: "uint256" }, + { name: "deadline", type: "uint256" }, + { name: "nonce", type: "bytes32" }, + { name: "referrer", type: "address" }, + { name: "sourceChainId", type: "uint32" }, + { name: "destinationChainId", type: "uint32" }, + ], + CommentIdentifier: commentIdentifierType, + } as const; + + return { + types: permitSparkCommentTypedDataType, + message, + primaryType: "PermitSparkComment", + domain: commentsDomain({ + signingChainId: message.sourceChainId, + destinationChainId: + message.destinationChainId as keyof typeof commentsAddress, + }), + account: signingAccount || message.sparker, + }; +}; + +// todo: explain +export const sparkValue = () => parseEther("0.000001"); + +/** + * Generates the typed data definition for a permit timed sale mint and comment operation. + * + * This function creates a structured data object that can be used for EIP-712 signing, + * allowing users to sign a message on one chain that permits a timed sale mint and comment + * action to be executed on another chain. + * + * @param message - The {@link PermitMintAndComment} containing the details of the permit. + * @param signingAccount - (optional) The account that is signing the message, if different from the commenter. + * This is typically used when the commenter is a smart wallet, and the signing account is one of its owners. + * @returns A {@link TypedDataDefinition} object compatible with EIP-712 for structured data hashing and signing, + * including types, message, primary type, domain, and the signer's account address. + */ +export const permitMintAndCommentTypedDataDefinition = ( + message: PermitMintAndComment, + signingAccount?: Address, +): TypedDataDefinition< + typeof permitTimedSaleMintAndCommentTypedDataType, + "PermitTimedSaleMintAndComment" +> & { account: Address } => { + const permitTimedSaleMintAndCommentTypedDataType = { + PermitTimedSaleMintAndComment: [ + { name: "commenter", type: "address" }, + { name: "quantity", type: "uint256" }, + { name: "collection", type: "address" }, + { name: "tokenId", type: "uint256" }, + { name: "mintReferral", type: "address" }, + { name: "comment", type: "string" }, + { name: "deadline", type: "uint256" }, + { name: "nonce", type: "bytes32" }, + { name: "sourceChainId", type: "uint32" }, + { name: "destinationChainId", type: "uint32" }, + ], + } as const; + + const callerAndCommenterDomain = ({ + signingChainId, + destinationChainId, + }: { + signingChainId: number; + destinationChainId: keyof typeof callerAndCommenterAddress; + }) => ({ + name: "CallerAndCommenter", + version: "1", + chainId: signingChainId, + verifyingContract: callerAndCommenterAddress[destinationChainId], + }); + + return { + types: permitTimedSaleMintAndCommentTypedDataType, + message, + primaryType: "PermitTimedSaleMintAndComment", + domain: callerAndCommenterDomain({ + signingChainId: message.sourceChainId, + destinationChainId: + message.destinationChainId as keyof typeof callerAndCommenterAddress, + }), + account: signingAccount || message.commenter, + }; +}; + +/** + * Generates the typed data definition for a permit buy on secondary and comment operation. + * + * This function creates a structured data object that can be used for EIP-712 signing, + * allowing users to sign a message on one chain that permits a buy on secondary market and comment + * action to be executed on another chain. + * + * @param message - The {@link PermitBuyOnSecondaryAndComment} containing the details of the permit. + * @param signingAccount - (optional) The account that is signing the message, if different from the commenter. + * This is typically used when the commenter is a smart wallet, and the signing account is one of its owners. + * @returns A {@link TypedDataDefinition} object compatible with EIP-712 for structured data hashing and signing, + * including types, message, primary type, domain, and the signer's account address. + */ +export const permitBuyOnSecondaryAndCommentTypedDataDefinition = ( + message: PermitBuyOnSecondaryAndComment, + signingAccount?: Address, +): TypedDataDefinition< + typeof permitBuyOnSecondaryAndCommentTypedDataType, + "PermitBuyOnSecondaryAndComment" +> & { account: Address } => { + const permitBuyOnSecondaryAndCommentTypedDataType = { + PermitBuyOnSecondaryAndComment: [ + { name: "commenter", type: "address" }, + { name: "quantity", type: "uint256" }, + { name: "collection", type: "address" }, + { name: "tokenId", type: "uint256" }, + { name: "maxEthToSpend", type: "uint256" }, + { name: "sqrtPriceLimitX96", type: "uint160" }, + { name: "comment", type: "string" }, + { name: "deadline", type: "uint256" }, + { name: "nonce", type: "bytes32" }, + { name: "sourceChainId", type: "uint32" }, + { name: "destinationChainId", type: "uint32" }, + ], + } as const; + + const callerAndCommenterDomain = ({ + signingChainId, + destinationChainId, + }: { + signingChainId: number; + destinationChainId: keyof typeof callerAndCommenterAddress; + }) => ({ + name: "CallerAndCommenter", + version: "1", + chainId: signingChainId, + verifyingContract: callerAndCommenterAddress[destinationChainId], + }); + + return { + types: permitBuyOnSecondaryAndCommentTypedDataType, + message, + primaryType: "PermitBuyOnSecondaryAndComment", + domain: callerAndCommenterDomain({ + signingChainId: message.sourceChainId, + destinationChainId: + message.destinationChainId as keyof typeof callerAndCommenterAddress, + }), + account: signingAccount || message.commenter, + }; +}; diff --git a/packages/protocol-deployments/src/types.ts b/packages/protocol-deployments/src/types.ts index b7f2da628..f0604e5fc 100644 --- a/packages/protocol-deployments/src/types.ts +++ b/packages/protocol-deployments/src/types.ts @@ -7,6 +7,8 @@ import { } from "./generated/wagmi"; import { Address } from "viem"; +import { commentsABI, callerAndCommenterABI } from "./generated/wagmi"; + export enum PremintConfigVersion { V1 = "1", V2 = "2", @@ -121,3 +123,69 @@ export type SponsoredSparksBatch = AbiParametersToPrimitiveTypes< "hashSponsoredMint" >["inputs"] >[0]; + +export type CommentIdentifier = AbiParametersToPrimitiveTypes< + ExtractAbiFunction["inputs"] +>[0]; + +export const emptyCommentIdentifier = (): CommentIdentifier => { + const zeroAddress = "0x0000000000000000000000000000000000000000"; + const zeroHash = + "0x0000000000000000000000000000000000000000000000000000000000000000"; + return { + commenter: zeroAddress, + contractAddress: zeroAddress, + tokenId: 0n, + nonce: zeroHash, + }; +}; + +/** + * The PermitComment type represents the data structure for a permit comment, + * for cross-chain commenting, where a user can sign a comment message on one chain, + * which can then be submitted by anyone on the destination chain to execute the comment action. + * + * The permit includes details such as the comment text, the commenter's address, + * the comment being replied to, and chain IDs for the source and destination chains. + */ +export type PermitComment = AbiParametersToPrimitiveTypes< + ExtractAbiFunction["inputs"] +>[0]; + +/** + * The PermitSparkComment type represents the data structure for a permit spark comment, + * for cross-chain sparking (liking with value) of comments, where a user can sign a spark comment message on one chain, + * which can then be submitted by anyone on the destination chain to execute the spark action. + * + * The permit includes details such as the comment to be sparked, the sparker's address, + * the quantity of sparks, and chain IDs for the source and destination chains. + */ +export type PermitSparkComment = AbiParametersToPrimitiveTypes< + ExtractAbiFunction["inputs"] +>[0]; + +/** + * The PermitTimedSaleMintAndComment type represents the data structure for a permit timed sale mint and comment, + * where a user can sign a message to mint during a timed sale and leave a comment in a single transaction. + * This can be executed on the destination chain by anyone. + * + * The permit includes details such as the minting parameters, comment text, and chain IDs for the source and destination chains. + */ +export type PermitMintAndComment = AbiParametersToPrimitiveTypes< + ExtractAbiFunction< + typeof callerAndCommenterABI, + "hashPermitTimedSaleMintAndComment" + >["inputs"] +>[0]; + +/** + * The PermitBuyOnSecondaryAndComment type represents the data structure for a permit buy on secondary market and comment, + * where a user can sign a message to buy on secondary market and leave a comment in a single transaction. + * This can be executed on the destination chain by anyone. + */ +export type PermitBuyOnSecondaryAndComment = AbiParametersToPrimitiveTypes< + ExtractAbiFunction< + typeof callerAndCommenterABI, + "hashPermitBuyOnSecondaryAndComment" + >["inputs"] +>[0]; diff --git a/packages/protocol-rewards/CHANGELOG.md b/packages/protocol-rewards/CHANGELOG.md index 8312bfbb1..6f7f116e8 100644 --- a/packages/protocol-rewards/CHANGELOG.md +++ b/packages/protocol-rewards/CHANGELOG.md @@ -1,5 +1,11 @@ # @zoralabs/protocol-rewards +## 1.2.5 + +### Patch Changes + +- ffc695d3: Added balanceOf to interface IProtocolRewards + ## 1.2.4 ### Patch Changes diff --git a/packages/protocol-rewards/package.json b/packages/protocol-rewards/package.json index 1d3c58b3b..c80991cdb 100644 --- a/packages/protocol-rewards/package.json +++ b/packages/protocol-rewards/package.json @@ -1,6 +1,6 @@ { "name": "@zoralabs/protocol-rewards", - "version": "1.2.4", + "version": "1.2.5", "repository": "https://github.com/ourzora/zora-protocol.git", "license": "MIT", "files": [ diff --git a/packages/protocol-rewards/src/interfaces/IProtocolRewards.sol b/packages/protocol-rewards/src/interfaces/IProtocolRewards.sol index 82fda6586..d246dccad 100644 --- a/packages/protocol-rewards/src/interfaces/IProtocolRewards.sol +++ b/packages/protocol-rewards/src/interfaces/IProtocolRewards.sol @@ -116,4 +116,8 @@ interface IProtocolRewards { /// @param r R component of signature /// @param s S component of signature function withdrawWithSig(address from, address to, uint256 amount, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external; + + /// @notice Get the balance of an account + /// @param account The account to get the balance of + function balanceOf(address account) external view returns (uint256); } diff --git a/packages/protocol-sdk/CHANGELOG.md b/packages/protocol-sdk/CHANGELOG.md index ce1f15509..ce76d6e5b 100644 --- a/packages/protocol-sdk/CHANGELOG.md +++ b/packages/protocol-sdk/CHANGELOG.md @@ -1,5 +1,14 @@ # @zoralabs/protocol-sdk +## 0.11.9 + +### Patch Changes + +- 9d5d1638: When minting + commenting, and using the timed sale strategy, protocol sdk will call the CallerAndCommenter contract +- 088ec6fb: When buying on secondary, you can now add a comment, which will call the CallerAndCommenter's buyOnSecondaryAndComment function. +- Updated dependencies [4928687d] + - @zoralabs/protocol-deployments@0.3.9 + ## 0.11.8 ### Patch Changes diff --git a/packages/protocol-sdk/package.json b/packages/protocol-sdk/package.json index 0c3c3df9c..d39e3414c 100644 --- a/packages/protocol-sdk/package.json +++ b/packages/protocol-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zoralabs/protocol-sdk", - "version": "0.11.8", + "version": "0.11.9", "repository": "https://github.com/ourzora/zora-protocol", "license": "MIT", "type": "module", @@ -25,11 +25,11 @@ "lint": "prettier --check 'src/**/*.ts' 'test-integration/**/*.ts'" }, "dependencies": { - "@zoralabs/protocol-deployments": "workspace:^", - "abitype": "^1.0.2" + "@zoralabs/protocol-deployments": "workspace:^" }, "peerDependencies": { - "viem": "^2.21.21" + "viem": "^2.21.21", + "abitype": "^1.0.2" }, "devDependencies": { "@lavamoat/preinstall-always-fail": "2.0.0", diff --git a/packages/protocol-sdk/src/comments/comments.test.ts b/packages/protocol-sdk/src/comments/comments.test.ts new file mode 100644 index 000000000..d26f1cbd7 --- /dev/null +++ b/packages/protocol-sdk/src/comments/comments.test.ts @@ -0,0 +1,338 @@ +import { describe, expect } from "vitest"; +import { forkUrls, makeAnvilTest, writeContractWithRetries } from "src/anvil"; +import { base, zora } from "viem/chains"; +import { + commentsABI, + commentsAddress, + emptyCommentIdentifier, + PermitComment, + PermitSparkComment, + sparkValue, + CommentIdentifier, + permitSparkCommentTypedDataDefinition, + permitMintAndCommentTypedDataDefinition, + PermitMintAndComment, + callerAndCommenterAddress as callerAndCommenterAddresses, + callerAndCommenterABI, + zoraCreator1155ImplABI, +} from "@zoralabs/protocol-deployments"; +import { + Address, + zeroAddress, + parseEther, + TransactionReceipt, + parseEventLogs, + hashTypedData, +} from "viem"; +import { createCreatorClient } from "src/sdk"; +import { randomNewContract, waitForSuccess } from "src/test-utils"; +import { permitCommentTypedDataDefinition } from "@zoralabs/protocol-deployments"; +import { demoTokenMetadataURI } from "src/fixtures/contract-setup"; +import { randomNonce, thirtySecondsFromNow } from "src/test-utils"; + +const getCommentIdentifierFromReceipt = ( + receipt: TransactionReceipt, +): CommentIdentifier => { + const logs = parseEventLogs({ + abi: commentsABI, + logs: receipt.logs, + eventName: "Commented", + }); + + if (logs.length === 0) { + throw new Error("No Commented event found in receipt"); + } + + return logs[0]!.args.commentIdentifier; +}; + +// todo: move this to protocol-deployments +describe("comments", () => { + makeAnvilTest({ + forkUrl: forkUrls.zoraMainnet, + forkBlockNumber: 21297211, + anvilChainId: zora.id, + })( + "can sign and execute a cross-chain comment, and sign and execute a cross-chain spark comment", + async ({ + viemClients: { publicClient, walletClient, chain, testClient }, + }) => { + // Get the chain ID and set up addresses for different roles + const chainId = chain.id; + const [ + collectorAddress, + creatorAddress, + executorAddress, + sparkerAddress, + ] = (await walletClient.getAddresses()!) as [ + Address, + Address, + Address, + Address, + ]; + + // Create a creator client for interacting with the protocol + const creatorClient = createCreatorClient({ + chainId, + publicClient, + }); + + // Step 1: Create a new 1155 contract and token + const { contractAddress, newTokenId, parameters, prepareMint } = + await creatorClient.create1155({ + contract: randomNewContract(), + token: { + tokenMetadataURI: demoTokenMetadataURI, + }, + account: creatorAddress, + }); + + // Deploy the new contract + const { request } = await publicClient.simulateContract(parameters); + await writeContractWithRetries({ + request, + walletClient, + publicClient, + }); + + // Prepare to mint a token + const { parameters: collectParameters } = await prepareMint({ + quantityToMint: 1n, + minterAccount: collectorAddress, + }); + + // Step 2: Mint an 1155 token on the new contract + const { request: mintRequest } = + await publicClient.simulateContract(collectParameters); + await writeContractWithRetries({ + request: mintRequest, + walletClient, + publicClient, + }); + + // Set up cross-chain comment parameters + const sourceChainId = base.id; // The chain ID where the comment originates + + const commentsAddressForChainId = + commentsAddress[chainId as keyof typeof commentsAddress]; + + // Step 3: Prepare a cross-chain comment + const permitComment: PermitComment = { + sourceChainId, + contractAddress, + destinationChainId: chainId, + tokenId: newTokenId, + commenter: collectorAddress, + text: "hello world", + deadline: thirtySecondsFromNow(), + nonce: randomNonce(), + referrer: zeroAddress, + commenterSmartWallet: zeroAddress, + replyTo: emptyCommentIdentifier(), + }; + + // Generate typed data for signing + const typedData = permitCommentTypedDataDefinition(permitComment); + + expect(typedData.domain!.chainId).toEqual(sourceChainId); + expect(typedData.account).toEqual(collectorAddress); + expect(typedData.domain!.verifyingContract).toEqual( + commentsAddressForChainId, + ); + expect(typedData.domain!.verifyingContract).toEqual( + commentsAddressForChainId, + ); + + const hashed = await publicClient.readContract({ + abi: commentsABI, + address: commentsAddressForChainId, + functionName: "hashPermitComment", + args: [permitComment], + }); + + expect(hashed).toEqual(hashTypedData(typedData)); + + // Ensure the commenter has enough balance for the spark value + await testClient.setBalance({ + address: collectorAddress, + value: sparkValue(), + }); + + // Step 4: Sign the cross-chain comment + const signature = await walletClient.signTypedData(typedData); + + // Ensure the executor has enough balance to execute the transaction + await testClient.setBalance({ + address: executorAddress, + value: parseEther("1"), + }); + + // Step 5: Simulate and execute the cross-chain comment + const { request: commentRequest } = await publicClient.simulateContract({ + abi: commentsABI, + address: commentsAddressForChainId, + functionName: "permitComment", + args: [permitComment, signature], + account: executorAddress, + value: sparkValue(), + }); + + const commentHash = await walletClient.writeContract(commentRequest); + const receipt = await publicClient.waitForTransactionReceipt({ + hash: commentHash, + }); + + expect(receipt.status).toBe("success"); + + // Extract the comment identifier from the transaction receipt + const commentIdentifier = getCommentIdentifierFromReceipt(receipt); + + // Step 6: Prepare a spark (like) for the comment + const sparkComment: PermitSparkComment = { + sparksQuantity: 3n, + sparker: sparkerAddress, + deadline: thirtySecondsFromNow(), + nonce: randomNonce(), + referrer: zeroAddress, + sourceChainId, + destinationChainId: chainId, + comment: commentIdentifier, + }; + + const sparkTypedData = + permitSparkCommentTypedDataDefinition(sparkComment); + + // Step 7: Sign the spark comment + const sparkSignature = await walletClient.signTypedData(sparkTypedData); + + // Step 8: Simulate and execute the spark comment + const { request: sparkRequest } = await publicClient.simulateContract({ + abi: commentsABI, + address: commentsAddressForChainId, + functionName: "permitSparkComment", + args: [sparkComment, sparkSignature], + account: executorAddress, + value: sparkValue() * sparkComment.sparksQuantity, + }); + + const sparkHash = await walletClient.writeContract(sparkRequest); + + await waitForSuccess(sparkHash, publicClient); + + // Step 9: Verify the spark count + const sparkCount = await publicClient.readContract({ + abi: commentsABI, + address: commentsAddressForChainId, + functionName: "commentSparksQuantity", + args: [commentIdentifier], + }); + + expect(sparkCount).toEqual(sparkComment.sparksQuantity); + }, + 40_000, + ); + makeAnvilTest({ + forkUrl: forkUrls.zoraMainnet, + forkBlockNumber: 21297211, + anvilChainId: zora.id, + })( + "can sign and execute a cross-chain timed sale mint and comment", + async ({ + viemClients: { publicClient, walletClient, chain, testClient }, + }) => { + // Get the chain ID and set up addresses for different roles + const chainId = chain.id; + const [commenterAddress, executorAddress] = + (await walletClient.getAddresses()!) as [Address, Address, Address]; + + // Create a creator client for interacting with the protocol + const creatorClient = createCreatorClient({ + chainId, + publicClient, + }); + + // Step 1: Create a new 1155 contract and token + const { contractAddress, newTokenId, parameters } = + await creatorClient.create1155({ + contract: randomNewContract(), + token: { + tokenMetadataURI: demoTokenMetadataURI, + }, + account: commenterAddress, + }); + + // Deploy the new contract + const { request } = await publicClient.simulateContract(parameters); + await writeContractWithRetries({ + request, + walletClient, + publicClient, + }); + + // Step 2: Prepare the permit data for timed sale mint and comment + const quantity = 3n; + const mintReferral = zeroAddress; + const comment = "This is a test comment for timed sale mint"; + + const permitMintAndComment: PermitMintAndComment = { + commenter: commenterAddress, + quantity, + collection: contractAddress, + tokenId: newTokenId, + mintReferral, + comment, + deadline: thirtySecondsFromNow(), + nonce: randomNonce(), + sourceChainId: chainId, + destinationChainId: chainId, + }; + + // Step 3: Generate the typed data for signing + const typedData = + permitMintAndCommentTypedDataDefinition(permitMintAndComment); + + // Step 4: Sign the typed data + const signature = await walletClient.signTypedData(typedData); + + await testClient.setBalance({ + address: executorAddress, + value: parseEther("1"), + }); + + // Step 5: Simulate and execute the permitTimedSaleMintAndComment function + const callerAndCommenterAddress = + callerAndCommenterAddresses[ + chainId as keyof typeof callerAndCommenterAddresses + ]; + const { request: permitRequest } = await publicClient.simulateContract({ + abi: callerAndCommenterABI, + address: callerAndCommenterAddress, + functionName: "permitTimedSaleMintAndComment", + args: [permitMintAndComment, signature], + account: executorAddress, + value: quantity * parseEther("0.000111"), + }); + + const receipt = await writeContractWithRetries({ + publicClient, + walletClient, + request: permitRequest, + }); + + // Step 6: Verify the comment was created + const commentIdentifier = getCommentIdentifierFromReceipt(receipt); + expect(commentIdentifier).toBeDefined(); + + // Step 7: Verify the token was minted + const balance = await publicClient.readContract({ + abi: zoraCreator1155ImplABI, + address: contractAddress, + functionName: "balanceOf", + args: [commenterAddress, newTokenId], + }); + + expect(balance).toEqual(quantity); + }, + 40_000, // Increased timeout to 30 seconds + ); +}); diff --git a/packages/protocol-sdk/src/create/1155-create-helper.test.ts b/packages/protocol-sdk/src/create/1155-create-helper.test.ts index 59567b910..203b90f92 100644 --- a/packages/protocol-sdk/src/create/1155-create-helper.test.ts +++ b/packages/protocol-sdk/src/create/1155-create-helper.test.ts @@ -21,16 +21,13 @@ import { import { zora } from "viem/chains"; import { AllowList } from "src/allow-list/types"; import { createAllowList } from "src/allow-list/allow-list-client"; -import { NewContractParams } from "./types"; import { SubgraphContractGetter } from "./contract-getter"; import { DEFAULT_MINIMUM_MARKET_ETH, DEFAULT_MARKET_COUNTDOWN, } from "./minter-defaults"; -import { - demoContractMetadataURI, - demoTokenMetadataURI, -} from "src/fixtures/contract-setup"; +import { randomNewContract } from "src/test-utils"; +import { demoTokenMetadataURI } from "src/fixtures/contract-setup"; const anvilTest = makeAnvilTest({ forkUrl: forkUrls.zoraMainnet, @@ -61,13 +58,6 @@ const minterIsMinterOnToken = async ({ }); }; -function randomNewContract(): NewContractParams { - return { - name: `testContract-${Math.round(Math.random() * 1_000_000)}`, - uri: demoContractMetadataURI, - }; -} - describe("create-helper", () => { anvilTest( "when no sales config is provided, it creates a new 1155 contract and token using the timed sale strategy", diff --git a/packages/protocol-sdk/src/create/1155-create-helper.ts b/packages/protocol-sdk/src/create/1155-create-helper.ts index 5a622ede0..4d0b02594 100644 --- a/packages/protocol-sdk/src/create/1155-create-helper.ts +++ b/packages/protocol-sdk/src/create/1155-create-helper.ts @@ -238,6 +238,7 @@ async function createNew1155ContractAndToken({ publicClient, chainId, }), + chainId, }); return { @@ -294,6 +295,7 @@ async function createNew1155Token({ result: newToken.salesConfig, tokenId: nextTokenId, getContractMintFee: async () => mintFee, + chainId, }); return { diff --git a/packages/protocol-sdk/src/create/mint-from-create.ts b/packages/protocol-sdk/src/create/mint-from-create.ts index c1e25a4e7..29587378e 100644 --- a/packages/protocol-sdk/src/create/mint-from-create.ts +++ b/packages/protocol-sdk/src/create/mint-from-create.ts @@ -75,6 +75,7 @@ export function makeOnchainPrepareMintFromCreate({ minter, getContractMintFee, contractVersion, + chainId, }: { contractAddress: Address; tokenId: bigint; @@ -82,6 +83,7 @@ export function makeOnchainPrepareMintFromCreate({ minter: Address; getContractMintFee: () => Promise; contractVersion: string; + chainId: number; }): AsyncPrepareMint { return async (params: MintParametersBase): Promise => { const subgraphSalesConfig = await toSalesStrategyFromSubgraph({ @@ -98,6 +100,7 @@ export function makeOnchainPrepareMintFromCreate({ ...params, tokenContract: contractAddress, tokenId, + chainId, }), costs: parseMintCosts({ allowListEntry: params.allowListEntry, diff --git a/packages/protocol-sdk/src/mint/mint-client.test.ts b/packages/protocol-sdk/src/mint/mint-client.test.ts index 169c7c035..6010fb075 100644 --- a/packages/protocol-sdk/src/mint/mint-client.test.ts +++ b/packages/protocol-sdk/src/mint/mint-client.test.ts @@ -1,16 +1,28 @@ import { describe, expect, vi } from "vitest"; -import { Address, erc20Abi, parseAbi, parseEther } from "viem"; +import { + Address, + erc20Abi, + parseAbi, + parseEther, + TransactionReceipt, + parseEventLogs, +} from "viem"; import { zora, zoraSepolia } from "viem/chains"; -import { zoraCreator1155ImplABI } from "@zoralabs/protocol-deployments"; +import { + zoraCreator1155ImplABI, + CommentIdentifier, + commentsABI, + callerAndCommenterABI, +} from "@zoralabs/protocol-deployments"; import { forkUrls, makeAnvilTest, writeContractWithRetries } from "src/anvil"; import { createCollectorClient, createCreatorClient } from "src/sdk"; import { getAllowListEntry } from "src/allow-list/allow-list-client"; +import { SubgraphMintGetter } from "./subgraph-mint-getter"; +import { new1155ContractVersion } from "src/create/contract-setup"; import { demoContractMetadataURI, demoTokenMetadataURI, -} from "src/create/1155-create-helper.test"; -import { SubgraphMintGetter } from "./subgraph-mint-getter"; -import { new1155ContractVersion } from "src/create/contract-setup"; +} from "src/fixtures/contract-setup"; import { ISubgraphQuerier } from "src/apis/subgraph-querier"; import { mockTimedSaleStrategyTokenQueryResult } from "src/fixtures/mint-query-results"; @@ -18,52 +30,64 @@ const erc721ABI = parseAbi([ "function balanceOf(address owner) public view returns (uint256)", ] as const); +const getCommentIdentifierFromReceipt = ( + receipt: TransactionReceipt, +): CommentIdentifier => { + const logs = parseEventLogs({ + abi: commentsABI, + logs: receipt.logs, + eventName: "Commented", + }); + + if (logs.length === 0) { + throw new Error("No Commented event found in receipt"); + } + + return logs[0]!.args.commentIdentifier; +}; + describe("mint-helper", () => { makeAnvilTest({ - forkBlockNumber: 16028671, - forkUrl: forkUrls.zoraMainnet, - anvilChainId: zora.id, + forkBlockNumber: 16028124, + forkUrl: forkUrls.zoraSepolia, + anvilChainId: zoraSepolia.id, })( - "mints a new 1155 token", + "mints a new 1155 token with a comment", async ({ viemClients }) => { - const { testClient, walletClient, publicClient } = viemClients; + const { testClient, walletClient, publicClient, chain } = viemClients; const creatorAccount = (await walletClient.getAddresses())[0]!; await testClient.setBalance({ address: creatorAccount, value: parseEther("2000"), }); const targetContract: Address = - "0xa2fea3537915dc6c7c7a97a82d1236041e6feb2e"; - const targetTokenId = 1n; + "0xD42557F24034b53e7340A40bb5813eF9Ba88F2b4"; + const targetTokenId = 3n; const collectorClient = createCollectorClient({ - chainId: zora.id, + chainId: chain.id, publicClient, }); - const { - token: mintable, - prepareMint, - primaryMintActive, - } = await collectorClient.getToken({ - tokenContract: targetContract, - mintType: "1155", - tokenId: targetTokenId, - }); - - mintable.maxSupply; - mintable.totalMinted; - mintable.tokenURI; - mintable; + const { prepareMint, primaryMintActive } = await collectorClient.getToken( + { + tokenContract: targetContract, + mintType: "1155", + tokenId: targetTokenId, + }, + ); expect(primaryMintActive).toBe(true); expect(prepareMint).toBeDefined(); + const quantityToMint = 5n; + const { parameters, costs } = prepareMint!({ minterAccount: creatorAccount, - quantityToMint: 1, + quantityToMint, + mintComment: "This is a fun comment :)", }); - expect(costs.totalCostEth).toBe(1n * parseEther("0.000777")); + expect(costs.totalCostEth).toBe(quantityToMint * parseEther("0.000111")); const oldBalance = await publicClient.readContract({ abi: zoraCreator1155ImplABI, @@ -84,7 +108,20 @@ describe("mint-helper", () => { }); expect(receipt).to.not.be.null; expect(oldBalance).to.be.equal(0n); - expect(newBalance).to.be.equal(1n); + expect(newBalance).to.be.equal(quantityToMint); + + // search for the Commented event in the logs + const commentIdentifier = getCommentIdentifierFromReceipt(receipt); + + expect(commentIdentifier).toBeDefined(); + + const logs = parseEventLogs({ + abi: callerAndCommenterABI, + logs: receipt.logs, + eventName: "MintedAndCommented", + }); + + expect(logs.length).toBe(1); }, 12 * 1000, ); diff --git a/packages/protocol-sdk/src/mint/mint-client.ts b/packages/protocol-sdk/src/mint/mint-client.ts index 862af57cf..65df04dc7 100644 --- a/packages/protocol-sdk/src/mint/mint-client.ts +++ b/packages/protocol-sdk/src/mint/mint-client.ts @@ -24,19 +24,22 @@ export class MintClient { private readonly publicClient: IPublicClient; private readonly mintGetter: IOnchainMintGetter; private readonly premintGetter: IPremintGetter; - + private readonly chainId: number; constructor({ publicClient, premintGetter, mintGetter, + chainId, }: { publicClient: IPublicClient; premintGetter: IPremintGetter; mintGetter: IOnchainMintGetter; + chainId: number; }) { this.publicClient = publicClient; this.mintGetter = mintGetter; this.premintGetter = premintGetter; + this.chainId = chainId; } /** @@ -54,6 +57,7 @@ export class MintClient { publicClient: this.publicClient, mintGetter: this.mintGetter, premintGetter: this.premintGetter, + chainId: this.chainId, }); } @@ -69,6 +73,7 @@ export class MintClient { mintGetter: this.mintGetter, premintGetter: this.premintGetter, publicClient: this.publicClient, + chainId: this.chainId, }); } @@ -87,6 +92,7 @@ export class MintClient { mintGetter: this.mintGetter, premintGetter: this.premintGetter, publicClient: this.publicClient, + chainId: this.chainId, }); } @@ -110,17 +116,20 @@ async function mint({ publicClient, mintGetter, premintGetter, + chainId, }: { parameters: MakeMintParametersArguments; publicClient: IPublicClient; mintGetter: IOnchainMintGetter; premintGetter: IPremintGetter; + chainId: number; }): Promise { const { prepareMint, primaryMintActive } = await getMint({ params: parameters, mintGetter, premintGetter, publicClient, + chainId, }); if (!primaryMintActive) { diff --git a/packages/protocol-sdk/src/mint/mint-queries.ts b/packages/protocol-sdk/src/mint/mint-queries.ts index 00a40d8ae..6223894ce 100644 --- a/packages/protocol-sdk/src/mint/mint-queries.ts +++ b/packages/protocol-sdk/src/mint/mint-queries.ts @@ -36,11 +36,13 @@ export async function getMint({ mintGetter, premintGetter, publicClient, + chainId, }: { params: GetMintParameters; mintGetter: IOnchainMintGetter; premintGetter: IPremintGetter; publicClient: IPublicClient; + chainId: number; }): Promise { const { tokenContract } = params; if (isOnChainMint(params)) { @@ -53,7 +55,7 @@ export async function getMint({ blockTime: blockTime, }); - return toMintableReturn(result); + return toMintableReturn(result, chainId); } const premint = await premintGetter.get({ @@ -103,17 +105,19 @@ export async function getMintsOfContract({ mintGetter, premintGetter, publicClient, + chainId, }: { params: GetMintsOfContractParameters; mintGetter: IOnchainMintGetter; premintGetter: IPremintGetter; publicClient: IPublicClient; + chainId: number; }): Promise<{ contract?: ContractInfo; tokens: MintableReturn[] }> { const onchainMints = ( await mintGetter.getContractMintable({ tokenAddress: params.tokenContract, }) - ).map(toMintableReturn); + ).map((result) => toMintableReturn(result, chainId)); const offchainMints = await getPremintsOfContractMintable({ mintGetter, @@ -270,7 +274,7 @@ function parsePremint({ } export const makeOnchainPrepareMint = - (result: OnchainSalesConfigAndTokenInfo): PrepareMint => + (result: OnchainSalesConfigAndTokenInfo, chainId: number): PrepareMint => (params: MintParametersBase) => { if (!result.salesConfig) { throw new Error("No valid sales config found for token"); @@ -280,6 +284,7 @@ export const makeOnchainPrepareMint = parameters: makeOnchainMintCall({ token: result as Concrete, mintParams: params, + chainId, }), erc20Approval: getRequiredErc20Approvals(params, result.salesConfig), costs: parseMintCosts({ @@ -290,7 +295,10 @@ export const makeOnchainPrepareMint = }; }; -function toMintableReturn(result: GetMintableReturn): MintableReturn { +function toMintableReturn( + result: GetMintableReturn, + chainId: number, +): MintableReturn { const primaryMintActive = result.primaryMintActive; if (!primaryMintActive) { return { @@ -306,7 +314,10 @@ function toMintableReturn(result: GetMintableReturn): MintableReturn { primaryMintActive, primaryMintEnd: result.primaryMintEnd, secondaryMarketActive: result.secondaryMarketActive, - prepareMint: makeOnchainPrepareMint(result.salesConfigAndTokenInfo), + prepareMint: makeOnchainPrepareMint( + result.salesConfigAndTokenInfo, + chainId, + ), }; } diff --git a/packages/protocol-sdk/src/mint/mint-transactions.ts b/packages/protocol-sdk/src/mint/mint-transactions.ts index 93003d5ec..b3cee1477 100644 --- a/packages/protocol-sdk/src/mint/mint-transactions.ts +++ b/packages/protocol-sdk/src/mint/mint-transactions.ts @@ -11,6 +11,8 @@ import { erc20MinterABI, zoraCreator1155ImplABI, zoraTimedSaleStrategyABI, + callerAndCommenterABI, + callerAndCommenterAddress, } from "@zoralabs/protocol-deployments"; import { zora721Abi, zora1155LegacyAbi } from "src/constants"; import { @@ -32,9 +34,11 @@ import { AllowListEntry } from "src/allow-list/types"; export function makeOnchainMintCall({ token, mintParams, + chainId, }: { token: Concrete; mintParams: Omit; + chainId: number; }): SimulateContractParametersWithAccount { if (token.mintType === "721") { return makePrepareMint721TokenParams({ @@ -48,6 +52,7 @@ export function makeOnchainMintCall({ salesConfigAndTokenInfo: token, tokenContract: token.contract.address, tokenId: token.tokenId!, + chainId, ...mintParams, }); } @@ -57,6 +62,69 @@ export type MintableParameters = Pick< "contractVersion" | "salesConfig" >; +function makeZoraTimedSaleStrategyMintCall({ + minterAccount, + salesConfigAndTokenInfo, + mintQuantity, + mintTo, + tokenContract, + tokenId, + mintReferral, + mintComment, + chainId, +}: { + minterAccount: Address | Account; + salesConfigAndTokenInfo: Concrete; + mintQuantity: bigint; + mintTo: Address; + tokenContract: Address; + tokenId: GenericTokenIdTypes; + mintReferral?: Address; + mintComment?: string; + chainId: number; +}) { + // if there is a mint comment, use the caller and commenter + if (mintComment && mintComment !== "") { + return makeContractParameters({ + abi: callerAndCommenterABI, + address: + callerAndCommenterAddress[ + chainId as keyof typeof callerAndCommenterAddress + ], + functionName: "timedSaleMintAndComment", + account: minterAccount, + value: + salesConfigAndTokenInfo.salesConfig.mintFeePerQuantity * mintQuantity, + args: [ + mintTo, + mintQuantity, + tokenContract, + tokenId, + mintReferral || zeroAddress, + mintComment, + ], + }); + } + + return makeContractParameters({ + abi: zoraTimedSaleStrategyABI, + functionName: "mint", + account: minterAccount, + address: salesConfigAndTokenInfo.salesConfig.address, + value: + salesConfigAndTokenInfo.salesConfig.mintFeePerQuantity * mintQuantity, + /* args: mintTo, quantity, collection, tokenId, mintReferral, comment */ + args: [ + mintTo, + mintQuantity, + tokenContract, + BigInt(tokenId), + mintReferral || zeroAddress, + "", + ], + }); +} + export function makePrepareMint1155TokenParams({ tokenContract: tokenContract, tokenId, @@ -67,9 +135,11 @@ export function makePrepareMint1155TokenParams({ mintRecipient, quantityToMint, allowListEntry, + chainId, }: { salesConfigAndTokenInfo: Concrete; tokenId: GenericTokenIdTypes; + chainId: number; } & Pick< MakeMintParametersArgumentsBase, | "minterAccount" @@ -101,22 +171,16 @@ export function makePrepareMint1155TokenParams({ } if (saleType === "timed") { - return makeContractParameters({ - abi: zoraTimedSaleStrategyABI, - functionName: "mint", - account: minterAccount, - address: salesConfigAndTokenInfo.salesConfig.address, - value: - salesConfigAndTokenInfo.salesConfig.mintFeePerQuantity * mintQuantity, - /* args: mintTo, quantity, collection, tokenId, mintReferral, comment */ - args: [ - mintTo, - mintQuantity, - tokenContract, - BigInt(tokenId), - mintReferral || zeroAddress, - mintComment || "", - ], + return makeZoraTimedSaleStrategyMintCall({ + minterAccount, + salesConfigAndTokenInfo, + mintQuantity, + mintTo, + tokenContract, + tokenId, + mintReferral, + mintComment, + chainId, }); } diff --git a/packages/protocol-sdk/src/sdk.ts b/packages/protocol-sdk/src/sdk.ts index 5ddd2b7dc..f78ccdb9e 100644 --- a/packages/protocol-sdk/src/sdk.ts +++ b/packages/protocol-sdk/src/sdk.ts @@ -116,6 +116,7 @@ export function createCollectorClient( publicClient: params.publicClient, premintGetter: premintGetterToUse, mintGetter: mintGetterToUse, + chainId: params.chainId, }); const secondaryClient = new SecondaryClient({ publicClient: params.publicClient, diff --git a/packages/protocol-sdk/src/secondary/secondary-client.test.ts b/packages/protocol-sdk/src/secondary/secondary-client.test.ts index 841ecc48f..eec07b7f8 100644 --- a/packages/protocol-sdk/src/secondary/secondary-client.test.ts +++ b/packages/protocol-sdk/src/secondary/secondary-client.test.ts @@ -1,5 +1,5 @@ import { describe, expect, vi } from "vitest"; -import { parseEther, Address } from "viem"; +import { parseEther, Address, parseEventLogs } from "viem"; import { zoraSepolia } from "viem/chains"; import { forkUrls, @@ -7,13 +7,22 @@ import { simulateAndWriteContractWithRetries, } from "src/anvil"; import { createCollectorClient } from "src/sdk"; -import { zoraCreator1155ImplABI } from "@zoralabs/protocol-deployments"; +import { + zoraCreator1155ImplABI, + commentsABI, + callerAndCommenterABI, + PermitBuyOnSecondaryAndComment, + permitBuyOnSecondaryAndCommentTypedDataDefinition, + callerAndCommenterAddress, + sparkValue, +} from "@zoralabs/protocol-deployments"; import { SubgraphMintGetter } from "src/mint/subgraph-mint-getter"; import { ERROR_SECONDARY_NOT_STARTED } from "./secondary-client"; import { ISubgraphQuerier } from "src/apis/subgraph-querier"; import { mockTimedSaleStrategyTokenQueryResult } from "src/fixtures/mint-query-results"; import { new1155ContractVersion } from "src/create/contract-setup"; import { advanceToSaleAndAndLaunchMarket } from "src/fixtures/secondary"; +import { randomNonce } from "src/test-utils"; describe("secondary", () => { makeAnvilTest({ @@ -257,4 +266,241 @@ describe("secondary", () => { }, 30_000, ); + + makeAnvilTest({ + forkBlockNumber: 16339853, + forkUrl: forkUrls.zoraSepolia, + anvilChainId: zoraSepolia.id, + })( + "it can buy on secondary with a comment", + async ({ + viemClients: { publicClient, chain, walletClient, testClient }, + }) => { + const collectorAccount = (await walletClient.getAddresses()!)[1]!; + const executorAccount = (await walletClient.getAddresses()!)[3]!; + + const mintGetter = new SubgraphMintGetter(chain.id); + const contractAddress: Address = + "0xd42557f24034b53e7340a40bb5813ef9ba88f2b4"; + const newTokenId = 4n; + + await testClient.setBalance({ + address: collectorAccount, + value: parseEther("100"), + }); + + mintGetter.subgraphQuerier.query = vi + .fn() + .mockResolvedValue({ + zoraCreateToken: mockTimedSaleStrategyTokenQueryResult({ + chainId: chain.id, + tokenId: newTokenId, + contractAddress, + contractVersion: + new1155ContractVersion[ + chain.id as keyof typeof new1155ContractVersion + ], + }), + }); + + const collectorClient = createCollectorClient({ + chainId: chain.id, + publicClient, + mintGetter, + }); + + const secondaryInfo = await collectorClient.getSecondaryInfo({ + contract: contractAddress, + tokenId: newTokenId, + }); + + // mint enough to start the countdown + const quantityToMint = + secondaryInfo!.minimumMintsForCountdown! - secondaryInfo!.mintCount; + + const { parameters: collectParameters } = await collectorClient.mint({ + minterAccount: collectorAccount, + mintType: "1155", + quantityToMint: quantityToMint, + tokenId: newTokenId, + tokenContract: contractAddress, + }); + + await simulateAndWriteContractWithRetries({ + parameters: collectParameters, + walletClient, + publicClient, + }); + + await advanceToSaleAndAndLaunchMarket({ + contractAddress, + tokenId: newTokenId, + testClient, + publicClient, + walletClient, + collectorClient, + chainId: chain.id, + account: collectorAccount, + }); + + const buyResult = await collectorClient.buy1155OnSecondary({ + account: collectorAccount, + quantity: 5n, + contract: contractAddress, + tokenId: newTokenId, + comment: "test comment", + }); + + const receipt = await simulateAndWriteContractWithRetries({ + parameters: buyResult.parameters!, + walletClient, + publicClient, + }); + + const commentedEvent = parseEventLogs({ + abi: commentsABI, + logs: receipt.logs, + eventName: "Commented", + }); + + expect(commentedEvent[0]).toBeDefined(); + expect(commentedEvent[0]!.args.text).toBe("test comment"); + + const boughtAndCommentedEvent = parseEventLogs({ + abi: callerAndCommenterABI, + logs: receipt.logs, + eventName: "SwappedOnSecondaryAndCommented", + }); + + expect(boughtAndCommentedEvent[0]).toBeDefined(); + expect(boughtAndCommentedEvent[0]!.args.comment).toBe("test comment"); + expect(boughtAndCommentedEvent[0]!.args.quantity).toBe(5n); + expect(boughtAndCommentedEvent[0]!.args.swapDirection).toBe(0); + + // PERMIT BUY TEST + const buyResultASecondTime = await collectorClient.buy1155OnSecondary({ + account: collectorAccount, + quantity: 5n, + contract: contractAddress, + tokenId: newTokenId, + comment: "test comment", + }); + + const valueToSend = (buyResultASecondTime.price!.wei.total * 105n) / 100n; + + const { timestamp } = await publicClient.getBlock(); + + const permitBuy: PermitBuyOnSecondaryAndComment = { + collection: contractAddress, + tokenId: newTokenId, + quantity: 5n, + commenter: collectorAccount, + comment: "test comment", + maxEthToSpend: valueToSend, + deadline: timestamp + 30n, + sqrtPriceLimitX96: 0n, + nonce: randomNonce(), + sourceChainId: chain.id, + destinationChainId: chain.id, + }; + + const permitBuySignature = await walletClient.signTypedData( + permitBuyOnSecondaryAndCommentTypedDataDefinition(permitBuy), + ); + + await testClient.setBalance({ + address: executorAccount, + value: parseEther("1"), + }); + + const { request } = await publicClient.simulateContract({ + abi: callerAndCommenterABI, + address: + callerAndCommenterAddress[ + chain.id as keyof typeof callerAndCommenterAddress + ], + functionName: "permitBuyOnSecondaryAndComment", + args: [permitBuy, permitBuySignature], + value: valueToSend, + account: executorAccount, + }); + + await simulateAndWriteContractWithRetries({ + parameters: request, + walletClient, + publicClient, + }); + + // now PERMIT SELL ON SECONDARY TEST + const quantityToSell = 3n; + + const sellResult = await collectorClient.sell1155OnSecondary({ + account: collectorAccount, + quantity: quantityToSell, + contract: contractAddress, + tokenId: newTokenId, + }); + + expect(sellResult.error).toBeUndefined(); + + // approve 1155s for callerAndCommenter to transfer, when selling + await simulateAndWriteContractWithRetries({ + parameters: { + abi: zoraCreator1155ImplABI, + address: contractAddress, + functionName: "setApprovalForAll", + account: collectorAccount, + args: [ + callerAndCommenterAddress[ + chain.id as keyof typeof callerAndCommenterAddress + ], + true, + ], + }, + walletClient, + publicClient, + }); + + // now sell on secondary with comment + // by calling the caller and commenter contract + // if including a comment, must send sparkValue() as value + // function signature: + // function sellOnSecondaryAndComment( + // address commenter, + // uint256 quantity, + // address collection, + // uint256 tokenId, + // address payable recipient, + // uint256 minEthToAcquire, + // uint160 sqrtPriceLimitX96, + // string calldata comment + // ) + await simulateAndWriteContractWithRetries({ + parameters: { + abi: callerAndCommenterABI, + address: + callerAndCommenterAddress[ + chain.id as keyof typeof callerAndCommenterAddress + ], + functionName: "sellOnSecondaryAndComment", + account: collectorAccount, + args: [ + collectorAccount, + quantityToSell, + contractAddress, + newTokenId, + collectorAccount, + // sell result with slippage + (sellResult.price!.wei.total * 95n) / 100n, + 0n, + "test comment", + ], + value: sparkValue(), + }, + walletClient, + publicClient, + }); + }, + 20_000, + ); }); diff --git a/packages/protocol-sdk/src/secondary/secondary-client.ts b/packages/protocol-sdk/src/secondary/secondary-client.ts index d2bae3d0d..9fcc93005 100644 --- a/packages/protocol-sdk/src/secondary/secondary-client.ts +++ b/packages/protocol-sdk/src/secondary/secondary-client.ts @@ -4,6 +4,8 @@ import { zoraCreator1155ImplABI, safeTransferSwapAbiParameters, secondarySwapABI, + callerAndCommenterABI, + callerAndCommenterAddress, } from "@zoralabs/protocol-deployments"; import { makeContractParameters, PublicClient } from "src/utils"; import { getUniswapQuote } from "./uniswap/uniswapQuote"; @@ -27,6 +29,8 @@ const ERROR_INSUFFICIENT_POOL_SUPPLY = "Insufficient pool supply"; const ERROR_SECONDARY_NOT_CONFIGURED = "Secondary not configured for given contract and token"; export const ERROR_SECONDARY_NOT_STARTED = "Secondary market has not started"; +export const ERROR_RECIPIENT_MISMATCH = + "Recipient must be the same as the caller if there is a comment"; // Helper function to create error objects function makeError(errorMessage: string) { @@ -46,6 +50,8 @@ type Call = }; async function makeBuy({ + contract, + tokenId, erc20z, poolBalance, amount, @@ -55,8 +61,11 @@ async function makeBuy({ chainId, slippage, publicClient, + comment, }: { erc20z: Address; + contract: Address; + tokenId: bigint; poolBalance: { erc20z: bigint }; amount: bigint; quantity: bigint; @@ -64,27 +73,144 @@ async function makeBuy({ recipient?: Address; chainId: number; slippage: number; + comment: string | undefined; publicClient: PublicClient; }): Promise { const costWithSlippage = calculateSlippageUp(amount, slippage); + const accountAddress = addressOrAccountAddress(account); - // we cannot buy all the available tokens in a pool (the quote fails if we try doing that) - const availableToBuy = poolBalance.erc20z / BigInt(1e18) - 1n; + const validationResult = await validateBuyConditions({ + poolBalance, + quantity, + costWithSlippage, + accountAddress, + publicClient, + }); - const accountAddress = addressOrAccountAddress(account); + if (validationResult.error) { + return makeError(validationResult.error); + } + if (comment && comment !== "") { + return handleBuyWithComment({ + accountAddress, + recipient, + chainId, + quantity, + contract, + tokenId, + costWithSlippage, + comment, + account, + }); + } + + return handleBuyWithoutComment({ + erc20z, + quantity, + recipient, + accountAddress, + costWithSlippage, + chainId, + account, + }); +} + +async function validateBuyConditions({ + poolBalance, + quantity, + costWithSlippage, + accountAddress, + publicClient, +}: { + poolBalance: { erc20z: bigint }; + quantity: bigint; + costWithSlippage: bigint; + accountAddress: Address; + publicClient: PublicClient; +}): Promise<{ error?: string }> { + const availableToBuy = poolBalance.erc20z / BigInt(1e18) - 1n; const availableToSpend = await publicClient.getBalance({ address: accountAddress, }); if (costWithSlippage > availableToSpend) { - return makeError(ERROR_INSUFFICIENT_WALLET_FUNDS); + return { error: ERROR_INSUFFICIENT_WALLET_FUNDS }; } if (availableToBuy < BigInt(quantity)) { - return makeError(ERROR_INSUFFICIENT_POOL_SUPPLY); + return { error: ERROR_INSUFFICIENT_POOL_SUPPLY }; + } + + return {}; +} + +function handleBuyWithComment({ + accountAddress, + recipient, + chainId, + quantity, + contract, + tokenId, + costWithSlippage, + comment, + account, +}: { + accountAddress: Address; + recipient?: Address; + chainId: number; + quantity: bigint; + contract: Address; + tokenId: bigint; + costWithSlippage: bigint; + comment: string; + account: Address | Account; +}): Call { + if (recipient && recipient !== accountAddress) { + return makeError(ERROR_RECIPIENT_MISMATCH); } + return { + parameters: makeContractParameters({ + abi: callerAndCommenterABI, + address: + callerAndCommenterAddress[ + chainId as keyof typeof callerAndCommenterAddress + ], + functionName: "buyOnSecondaryAndComment", + args: [ + accountAddress, + quantity, + contract, + tokenId, + accountAddress, + costWithSlippage, + 0n, + comment, + ], + account, + value: costWithSlippage, + }), + }; +} + +function handleBuyWithoutComment({ + erc20z, + quantity, + recipient, + accountAddress, + costWithSlippage, + chainId, + account, +}: { + erc20z: Address; + quantity: bigint; + recipient?: Address; + accountAddress: Address; + costWithSlippage: bigint; + chainId: number; + account: Address | Account; +}): Call { return { parameters: makeContractParameters({ abi: secondarySwapABI, @@ -99,7 +225,7 @@ async function makeBuy({ costWithSlippage, 0n, ], - account: account, + account, value: costWithSlippage, }), }; @@ -125,6 +251,7 @@ export async function buyWithSlippage({ account, slippage = UNISWAP_SLIPPAGE, recipient, + comment, }: BuyWithSlippageInput & { chainId: number; publicClient: PublicClient; @@ -159,6 +286,8 @@ export async function buyWithSlippage({ const call = await makeBuy({ erc20z, + contract, + tokenId, poolBalance, amount, quantity, @@ -166,6 +295,7 @@ export async function buyWithSlippage({ recipient, chainId, slippage, + comment, publicClient, }); diff --git a/packages/protocol-sdk/src/secondary/types.ts b/packages/protocol-sdk/src/secondary/types.ts index 190f06ebf..47955d423 100644 --- a/packages/protocol-sdk/src/secondary/types.ts +++ b/packages/protocol-sdk/src/secondary/types.ts @@ -47,6 +47,8 @@ export type BuyWithSlippageInput = { slippage?: number; // Optional recipient address (if different from buyer/seller) recipient?: Address; + // Optional comment to add to the swap + comment?: string; }; // Same structure as BuyWithSlippageInput diff --git a/packages/protocol-sdk/src/sparks/sparks-sponsored-sparks-spender.test.ts b/packages/protocol-sdk/src/sparks/sparks-sponsored-sparks-spender.test.ts index 99cdaa79a..40abd4771 100644 --- a/packages/protocol-sdk/src/sparks/sparks-sponsored-sparks-spender.test.ts +++ b/packages/protocol-sdk/src/sparks/sparks-sponsored-sparks-spender.test.ts @@ -179,5 +179,6 @@ describe("Sponsored Mints Spender with Relay", () => { expect(transferReceipt.status).toBe("success"); }, + 20_000, ); }); diff --git a/packages/protocol-sdk/src/test-utils.ts b/packages/protocol-sdk/src/test-utils.ts index 9071b46fd..f86caac8b 100644 --- a/packages/protocol-sdk/src/test-utils.ts +++ b/packages/protocol-sdk/src/test-utils.ts @@ -7,8 +7,11 @@ import { Hex, PublicClient, encodeAbiParameters, + keccak256, + toBytes, parseAbiParameters, } from "viem"; +import { NewContractParams } from "./create/types"; import { expect } from "vitest"; export const waitForSuccess = async (hash: Hex, publicClient: PublicClient) => { @@ -17,6 +20,8 @@ export const waitForSuccess = async (hash: Hex, publicClient: PublicClient) => { }); expect(receipt.status).toBe("success"); + + return receipt; }; export const getFixedPricedMinter = async ({ @@ -37,3 +42,17 @@ export const fixedPriceMinterMinterArguments = ({ }: { mintRecipient: Address; }) => encodeAbiParameters(parseAbiParameters("address"), [mintRecipient]); + +const demoContractMetadataURI = "ipfs://DUMMY/contract.json"; + +export function randomNewContract(): NewContractParams { + return { + name: `testContract-${Math.round(Math.random() * 1_000_000)}`, + uri: demoContractMetadataURI, + }; +} + +export const randomNonce = () => + keccak256(toBytes(Math.round(Math.random() * 1000))); +export const thirtySecondsFromNow = () => + BigInt(Math.round(new Date().getTime() / 1000)) + 30n; diff --git a/packages/shared-contracts/chainConfigs/11155420.json b/packages/shared-contracts/chainConfigs/11155420.json new file mode 100644 index 000000000..b3b3d8a9a --- /dev/null +++ b/packages/shared-contracts/chainConfigs/11155420.json @@ -0,0 +1,7 @@ +{ + "NONFUNGIBLE_POSITION_MANAGER": "0xdA75cEf1C93078e8b736FCA5D5a30adb97C8957d", + "UNISWAP_SWAP_ROUTER": "0x94cC0AaC535CCDB3C01d6787D6413C739ae12bc4", + "PROXY_ADMIN": "0xFA3748b2dcF94a9CBdb5087333E9F093476e8389", + "ZORA_RECIPIENT": "0xFA3748b2dcF94a9CBdb5087333E9F093476e8389", + "WETH": "0x4200000000000000000000000000000000000006" +} diff --git a/packages/shared-contracts/deterministicConfig/deployerAndCaller.json b/packages/shared-contracts/deterministicConfig/deployerAndCaller.json new file mode 100644 index 000000000..641e466b7 --- /dev/null +++ b/packages/shared-contracts/deterministicConfig/deployerAndCaller.json @@ -0,0 +1,5 @@ +{ + "creationCode": "0x610180604081815234620001b0576200001882620001b5565b601e825260208201907f44657465726d696e69737469634465706c6f796572416e6443616c6c6572000082528051926200005284620001b5565b6001845260208401603160f81b81526200006c82620001d1565b936101209485526200007e86620003a4565b92610140938452519020948560e05251902093610100948086524660a05283519060208201927f8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f84528583015260608201524660808201523060a082015260a0815260c081019060018060401b0392818310848411176200019a57828652815190206080523060c052610711928382019060c08201908482109111176200019a57600093620014738439039082f591821562000190576101609283525192610f21948562000552863960805185610d5c015260a05185610e28015260c05185610d2d015260e05185610dab01525184610dd10152518361030e0152518261033801525181818161049601526106ee0152f35b513d6000823e3d90fd5b634e487b7160e01b600052604160045260246000fd5b600080fd5b604081019081106001600160401b038211176200019a57604052565b8051602091908281101562000270575090601f8251116200020f57808251920151908083106200020057501790565b82600019910360031b1b161790565b90604051809263305a27a960e01b82528060048301528251908160248401526000935b82851062000256575050604492506000838284010152601f80199101168101030190fd5b848101820151868601604401529381019385935062000232565b6001600160401b0381116200019a576000928354926001938481811c9116801562000399575b838210146200038557601f81116200034f575b5081601f8411600114620002e857509282939183928694620002dc575b50501b916000199060031b1c191617905560ff90565b015192503880620002c6565b919083601f1981168780528488209488905b888383106200033457505050106200031a575b505050811b01905560ff90565b015160001960f88460031b161c191690553880806200030d565b858701518855909601959485019487935090810190620002fa565b85805284601f848820920160051c820191601f860160051c015b82811062000379575050620002a9565b87815501859062000369565b634e487b7160e01b86526022600452602486fd5b90607f169062000296565b805160209081811015620004325750601f825111620003d157808251920151908083106200020057501790565b90604051809263305a27a960e01b82528060048301528251908160248401526000935b82851062000418575050604492506000838284010152601f80199101168101030190fd5b8481018201518686016044015293810193859350620003f4565b9192916001600160401b0381116200019a5760019182548381811c9116801562000546575b828210146200053057601f8111620004f7575b5080601f8311600114620004aa5750819293946000926200049e575b5050600019600383901b1c191690821b17905560ff90565b01519050388062000486565b90601f198316958460005282600020926000905b888210620004df57505083859697106200031a57505050811b01905560ff90565b808785968294968601518155019501930190620004be565b8360005283601f83600020920160051c820191601f850160051c015b828110620005235750506200046a565b6000815501849062000513565b634e487b7160e01b600052602260045260246000fd5b90607f16906200045756fe6040608081526004908136101561001557600080fd5b6000803560e01c806325746d39146104ba5780637db68ec41461044b57806384b0196e146102d8578063868810341461022e578063e3867c2914610148578063eae49c87146100f15763f9baed181461006d57600080fd5b346100ea5760607ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126100ea5767ffffffffffffffff6024358181116100ed576100bc9036908601610622565b916044359182116100ea5750926100e3916100dc60209536908401610622565b9135610723565b9051908152f35b80fd5b8280fd5b50903461014457817ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610144576101409061012d6106d4565b9051918291602083526020830190610691565b0390f35b5080fd5b5060a07ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126100ea5767ffffffffffffffff9280358481116100ed576101939036908301610622565b936024359160443582811161022a576101af9036908301610622565b9160643590811161022a576101c691369101610622565b916084359573ffffffffffffffffffffffffffffffffffffffff9485881688036100ea5750918161021d8161021861020f60209b9761020a8a886102229c9a610723565b610bf3565b90929192610c2f565b6107d1565b61082d565b915191168152f35b8480fd5b50823461014457602091827ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126100ea5781359067ffffffffffffffff82116100ea57506101409161028491369101610622565b926102c9836102916106d4565b95835196816102a9899351809286808701910161066e565b82016102bd8251809386808501910161066e565b010380875201856105a7565b51928284938452830190610691565b509190346100ed57827ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126100ed576103327f00000000000000000000000000000000000000000000000000000000000000006109a5565b9261035c7f0000000000000000000000000000000000000000000000000000000000000000610b1a565b90825192602092602085019585871067ffffffffffffffff88111761041f57509260206103d58388966103c8998b9996528686528151998a997f0f000000000000000000000000000000000000000000000000000000000000008b5260e0868c015260e08b0190610691565b91898303908a0152610691565b924660608801523060808801528460a088015286840360c088015251928381520193925b82811061040857505050500390f35b8351855286955093810193928101926001016103f9565b8360416024927f4e487b7100000000000000000000000000000000000000000000000000000000835252fd5b50903461014457817ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610144576020905173ffffffffffffffffffffffffffffffffffffffff7f0000000000000000000000000000000000000000000000000000000000000000168152f35b5060807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126100ea5782359267ffffffffffffffff90602435828111610558576105099036908301610622565b916044359081116105585761052091369101610622565b906064359473ffffffffffffffffffffffffffffffffffffffff9384871687036100ea575091602095918361021d61022295336107d1565b8380fd5b6040810190811067ffffffffffffffff82111761057857604052565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b90601f7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0910116810190811067ffffffffffffffff82111761057857604052565b67ffffffffffffffff811161057857601f017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe01660200190565b81601f8201121561066957803590610639826105e8565b9261064760405194856105a7565b8284526020838301011161066957816000926020809301838601378301015290565b600080fd5b60005b8381106106815750506000910152565b8181015183820152602001610671565b907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0601f6020936106cd8151809281875287808801910161066e565b0116010190565b60405173ffffffffffffffffffffffffffffffffffffffff7f0000000000000000000000000000000000000000000000000000000000000000166020820152602081526107208161055c565b90565b906020815191012091602081519101206040519260208401927fdc039731015eea17e316614c8f3af7dca5fc4664683c8e8f2adc4e0ff71a7c9284526040850152606084015260808301526080825260a082019180831067ffffffffffffffff8411176105785760429260405251902061079b610d16565b90604051917f19010000000000000000000000000000000000000000000000000000000000008352600283015260228201522090565b73ffffffffffffffffffffffffffffffffffffffff1690818160601c036107f6575050565b60449250604051917f4d2da7e200000000000000000000000000000000000000000000000000000000835260048301526024820152fd5b9160559391928351936020810194852093600b604095865190878201528460208201523081520160ff815373ffffffffffffffffffffffffffffffffffffffff80988192201691169080820361096f575050805115610946575160009485f59384161561091d5782816020829351910182875af13d15610914573d6108b1816105e8565b906108be845192836105a7565b8152809360203d92013e5b156108d357505090565b6109109250519182917fa5fa8d2b000000000000000000000000000000000000000000000000000000008352602060048401526024830190610691565b0390fd5b606092506108c9565b600482517f741752c2000000000000000000000000000000000000000000000000000000008152fd5b600484517f4ca249dc000000000000000000000000000000000000000000000000000000008152fd5b604492508551917f12ae30e500000000000000000000000000000000000000000000000000000000835260048301526024820152fd5b60ff81146109fb5760ff811690601f82116109d157604051916109c78361055c565b8252602082015290565b60046040517fb3512b0c000000000000000000000000000000000000000000000000000000008152fd5b50604051600080549060018260011c9060018416938415610b10575b6020948584108114610ae35783875286949392918115610aa45750600114610a48575b5050610720925003826105a7565b60008080527f290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e56395935091905b818310610a8c57505061072093508201013880610a3a565b85548784018501529485019486945091830191610a74565b90506107209593507fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0091501682840152151560051b8201013880610a3a565b6024857f4e487b710000000000000000000000000000000000000000000000000000000081526022600452fd5b91607f1691610a17565b60ff8114610b3c5760ff811690601f82116109d157604051916109c78361055c565b506040516000600190600154918260011c9060018416938415610be9575b6020948584108114610ae35783875286949392918115610aa45750600114610b8a575050610720925003826105a7565b9093915060016000527fb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6936000915b818310610bd157505061072093508201013880610a3a565b85548784018501529485019486945091830191610bb9565b91607f1691610b5a565b8151919060418303610c2457610c1d92506020820151906060604084015193015160001a90610e4e565b9192909190565b505060009160029190565b6004811015610ce75780610c41575050565b60018103610c735760046040517ff645eedf000000000000000000000000000000000000000000000000000000008152fd5b60028103610cac57602482604051907ffce698f70000000000000000000000000000000000000000000000000000000082526004820152fd5b600314610cb65750565b602490604051907fd78bce0c0000000000000000000000000000000000000000000000000000000082526004820152fd5b7f4e487b7100000000000000000000000000000000000000000000000000000000600052602160045260246000fd5b73ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000000000000000000000000000000000000000000016301480610e25575b15610d7e577f000000000000000000000000000000000000000000000000000000000000000090565b60405160208101907f8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f82527f000000000000000000000000000000000000000000000000000000000000000060408201527f000000000000000000000000000000000000000000000000000000000000000060608201524660808201523060a082015260a0815260c0810181811067ffffffffffffffff8211176105785760405251902090565b507f00000000000000000000000000000000000000000000000000000000000000004614610d55565b91907f7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a08411610edf57926020929160ff608095604051948552168484015260408301526060820152600092839182805260015afa15610ed357805173ffffffffffffffffffffffffffffffffffffffff811615610eca57918190565b50809160019190565b604051903d90823e3d90fd5b5050506000916003919056fea264697066735822122050051f8990c8b9fb4d19714749191f9bd1987ed796ec88d5e23c0dab0d3f402f64736f6c6343000817003360c0806040523461003457306080523360a0526106d7908161003a823960805181818161019801526102bf015260a051815050f35b600080fdfe6040608081526004908136101561001557600080fd5b600091823560e01c80634f1ef2861461021057806352d1902d146101505763ad3cb1cc1461004257600080fd5b3461014c57827ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261014c578151908282019082821067ffffffffffffffff83111761012057508252600581526020907f352e302e300000000000000000000000000000000000000000000000000000006020820152825193849260208452825192836020860152825b84811061010a57505050828201840152601f017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0168101030190f35b81810183015188820188015287955082016100ce565b8460416024927f4e487b7100000000000000000000000000000000000000000000000000000000835252fd5b8280fd5b50913461020d57807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261020d575073ffffffffffffffffffffffffffffffffffffffff7f00000000000000000000000000000000000000000000000000000000000000001630036101e757602090517f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc8152f35b517fe07c8dba000000000000000000000000000000000000000000000000000000008152fd5b80fd5b5090807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261014c5781359073ffffffffffffffffffffffffffffffffffffffff93848316928381036105535760249586359167ffffffffffffffff831161054f573660238401121561054f57828701359161028d836105c7565b9061029a87519283610557565b83825260209384830195368c838301011161054b578188928d889301893784010152807f00000000000000000000000000000000000000000000000000000000000000001680301491821561051d575b50506104f55785517f52d1902d00000000000000000000000000000000000000000000000000000000815283818a818b5afa8691816104c2575b506103595750505050505051917f4c9c8ce3000000000000000000000000000000000000000000000000000000008352820152fd5b9088888894938c7f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc918281036104955750853b15610468575080547fffffffffffffffffffffffff000000000000000000000000000000000000000016821790558451889392917fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b8580a28251156104315750506104239582915190845af4913d15610427573d61041561040c826105c7565b92519283610557565b81528581943d92013e610601565b5080f35b5060609250610601565b95509550505050503461044357505080f35b7fb398979f000000000000000000000000000000000000000000000000000000008152fd5b83838851917f4c9c8ce3000000000000000000000000000000000000000000000000000000008352820152fd5b84908851917faa1d49a4000000000000000000000000000000000000000000000000000000008352820152fd5b9091508481813d83116104ee575b6104da8183610557565b810103126104ea57519038610324565b8680fd5b503d6104d0565b8786517fe07c8dba000000000000000000000000000000000000000000000000000000008152fd5b7f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc54161415905038806102ea565b8780fd5b8380fd5b5080fd5b90601f7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0910116810190811067ffffffffffffffff82111761059857604052565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b67ffffffffffffffff811161059857601f017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe01660200190565b90610640575080511561061657805190602001fd5b60046040517f1425ea42000000000000000000000000000000000000000000000000000000008152fd5b81511580610698575b610651575090565b60249073ffffffffffffffffffffffffffffffffffffffff604051917f9996b315000000000000000000000000000000000000000000000000000000008352166004820152fd5b50803b1561064956fea2646970667358221220459bf13b99c777a5b950cd141d312c7b6d057d7d56c0abcef89eca96d9fea32c64736f6c63430008170033", + "deployedAddress": "0xe95E6871e187Cc57ECBBd3E80e676B1E879BE36E", + "salt": "0x0000000000000000000000000000000000000000000000000000000000000000" +} \ No newline at end of file diff --git a/packages/shared-contracts/package.json b/packages/shared-contracts/package.json index 235df9738..63425bb15 100644 --- a/packages/shared-contracts/package.json +++ b/packages/shared-contracts/package.json @@ -10,15 +10,17 @@ "prettier:check": "prettier --check 'src/**/*.sol'", "prettier:write": "prettier --write 'src/**/*.sol'" }, - "dependencies": {}, + "dependencies": { + "@openzeppelin/contracts-upgradeable": "^5.1.0" + }, "devDependencies": { - "glob": "^10.2.2", - "prettier": "^3.0.3", - "prettier-plugin-solidity": "^1.3.1", - "tsx": "^4.16.3", "@openzeppelin/contracts": "^5.0.2", "ds-test": "https://github.com/dapphub/ds-test#cd98eff28324bfac652e63a239a60632a761790b", "forge-std": "https://github.com/foundry-rs/forge-std#705263c95892a906d7af65f0f73ce8a4a0c80b80", - "solady": "^0.0.168" + "glob": "^10.2.2", + "prettier": "^3.0.3", + "prettier-plugin-solidity": "^1.3.1", + "solady": "^0.0.168", + "tsx": "^4.16.3" } } diff --git a/packages/shared-contracts/remappings.txt b/packages/shared-contracts/remappings.txt index 27618b21f..038294a09 100644 --- a/packages/shared-contracts/remappings.txt +++ b/packages/shared-contracts/remappings.txt @@ -1,4 +1,5 @@ ds-test/=node_modules/ds-test/src/ forge-std/=node_modules/forge-std/src/ solady/=node_modules/solady/src/ -@openzeppelin/contracts/=node_modules/@openzeppelin/contracts/ \ No newline at end of file +@openzeppelin/contracts/=node_modules/@openzeppelin/contracts/ +@openzeppelin/contracts-upgradeable/=node_modules/@openzeppelin/contracts-upgradeable/ \ No newline at end of file diff --git a/packages/shared-contracts/src/deployment/ProxyDeployerScript.sol b/packages/shared-contracts/src/deployment/ProxyDeployerScript.sol index 7a2d03675..f0a3ddc6c 100644 --- a/packages/shared-contracts/src/deployment/ProxyDeployerScript.sol +++ b/packages/shared-contracts/src/deployment/ProxyDeployerScript.sol @@ -22,14 +22,6 @@ interface ISymbol { contract ProxyDeployerScript is CommonBase { using stdJson for string; - /// @notice Return a prefixed key for reading with a ".". - /// @param key key to prefix - /// @return prefixed key - function getKeyPrefix(string memory key) internal pure returns (string memory) { - return string.concat(".", key); - } - - function readAddressOrDefaultToZero(string memory json, string memory key) internal view returns (address) { string memory keyPrefix = getKeyPrefix(key); @@ -40,16 +32,6 @@ contract ProxyDeployerScript is CommonBase { } } - function readStringOrDefaultToEmpty(string memory json, string memory key) internal view returns (string memory) { - string memory keyPrefix = getKeyPrefix(key); - - if (vm.keyExists(json, keyPrefix)) { - return json.readString(keyPrefix); - } else { - return ""; - } - } - // copied from: https://github.com/karmacoma-eth/foundry-playground/blob/main/script/MineSaltScript.sol#L17C1-L36C9 function mineSalt( address deployer, @@ -284,4 +266,29 @@ contract ProxyDeployerScript is CommonBase { revert("Mimsatched deployer address"); } } + + /// @notice Return a prefixed key for reading with a ".". + /// @param key key to prefix + /// @return prefixed key + function getKeyPrefix(string memory key) internal pure returns (string memory) { + return string.concat(".", key); + } + + function readStringOrDefaultToEmpty(string memory json, string memory key) internal view returns (string memory str) { + string memory keyPrefix = getKeyPrefix(key); + + if (vm.keyExists(json, keyPrefix)) { + str = json.readString(keyPrefix); + } else { + str = ""; + } + } + + function readUintOrDefaultToZero(string memory json, string memory key) internal view returns (uint256 num) { + string memory keyPrefix = getKeyPrefix(key); + + if (vm.keyExists(json, keyPrefix)) { + num = vm.parseUint(json.readString(keyPrefix)); + } + } } diff --git a/packages/shared-contracts/src/interfaces/IContractMetadata.sol b/packages/shared-contracts/src/interfaces/IContractMetadata.sol index 6b8de68ab..598f88d20 100644 --- a/packages/shared-contracts/src/interfaces/IContractMetadata.sol +++ b/packages/shared-contracts/src/interfaces/IContractMetadata.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.17; +pragma solidity >=0.8.17; interface IHasContractName { /// @notice Contract name returns the pretty contract name diff --git a/packages/shared-contracts/src/interfaces/IVersionedContract.sol b/packages/shared-contracts/src/interfaces/IVersionedContract.sol index 5b69305a5..f78a48e61 100644 --- a/packages/shared-contracts/src/interfaces/IVersionedContract.sol +++ b/packages/shared-contracts/src/interfaces/IVersionedContract.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.17; +pragma solidity >=0.8.17; interface IVersionedContract { function contractVersion() external pure returns (string memory); diff --git a/packages/shared-contracts/src/upgrades/UpgradeBaseLib.sol b/packages/shared-contracts/src/upgrades/UpgradeBaseLib.sol index bce4ef9eb..d3f5e8cd3 100644 --- a/packages/shared-contracts/src/upgrades/UpgradeBaseLib.sol +++ b/packages/shared-contracts/src/upgrades/UpgradeBaseLib.sol @@ -3,12 +3,18 @@ pragma solidity ^0.8.17; import {CommonBase} from "forge-std/Base.sol"; import {console2, stdJson} from "forge-std/Script.sol"; +import {IVersionedContract} from "../interfaces/IVersionedContract.sol"; interface UUPSUpgradeableUpgradeTo { function upgradeTo(address) external; + function upgradeToAndCall(address, bytes calldata) external; } +interface GetImplementation { + function implementation() external view returns (address); +} + contract UpgradeBaseLib is CommonBase { using stdJson for string; @@ -145,11 +151,21 @@ contract UpgradeBaseLib is CommonBase { vm.stopPrank(); } - function readMissingUpgradePaths() internal view returns (address[] memory upgradePathTargets, bytes[] memory upgradePathCalls) { string memory json = vm.readFile(string.concat("./versions/", string.concat(vm.toString(block.chainid), ".json"))); upgradePathTargets = json.readAddressArray(".missingUpgradePathTargets"); upgradePathCalls = json.readBytesArray(".missingUpgradePathCalls"); } + + function getUpgradeNeeded(address proxy, address targetImpl) internal view returns (bool) { + try GetImplementation(proxy).implementation() returns (address currentImpl) { + return currentImpl != targetImpl; + } catch { + // If implementation() call fails, compare contract versions + string memory proxyVersion = IVersionedContract(proxy).contractVersion(); + string memory targetVersion = IVersionedContract(targetImpl).contractVersion(); + return keccak256(abi.encodePacked(proxyVersion)) != keccak256(abi.encodePacked(targetVersion)); + } + } } diff --git a/packages/shared-contracts/src/utils/UnorderedNoncesUpgradeable.sol b/packages/shared-contracts/src/utils/UnorderedNoncesUpgradeable.sol new file mode 100644 index 000000000..dd7ead6d3 --- /dev/null +++ b/packages/shared-contracts/src/utils/UnorderedNoncesUpgradeable.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * @dev Provides tracking nonces for addresses. Nonces can be in any order and just need to be unique. + */ +abstract contract UnorderedNoncesUpgradeable { + /** + * @dev The nonce used for an `account` is not the expected current nonce. + */ + error InvalidAccountNonce(address account, bytes32 currentNonce); + + /// @custom:storage-location erc7201:unorderedNonces.storage.UnorderedNoncesStorage + struct UnorderedNoncesStorage { + mapping(address account => mapping(bytes32 => bool)) nonces; + } + + // keccak256(abi.encode(uint256(keccak256("unorderedNonces.storage.UnorderedNoncesStorage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant UNORDERED_NONCES_STORAGE_LOCATION = 0xc84b62be2e432010aa71cc1bbdba4c7b02245544521aa5beae20093c70622400; + + function _getUnorderedNoncesStorage() private pure returns (UnorderedNoncesStorage storage $) { + assembly { + $.slot := UNORDERED_NONCES_STORAGE_LOCATION + } + } + + /** + * @dev Returns whether a nonce has been used for an address. + */ + function nonceUsed(address owner, bytes32 nonce) public view virtual returns (bool) { + return _getUnorderedNoncesStorage().nonces[owner][nonce]; + } + + /** + * @dev Same as {_useNonce} but checking that `nonce` passed in is valid. + */ + function _useCheckedNonce(address owner, bytes32 nonce) internal virtual { + UnorderedNoncesStorage storage $ = _getUnorderedNoncesStorage(); + if ($.nonces[owner][nonce]) { + revert InvalidAccountNonce(owner, nonce); + } + $.nonces[owner][nonce] = true; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4adc2d396..351c0b69b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,6 +96,9 @@ importers: specifier: 2.3.4 version: 2.3.4 devDependencies: + '@reservoir0x/relay-sdk': + specifier: ^1.3.3 + version: 1.3.3(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8)) '@zoralabs/tsconfig': specifier: workspace:* version: link:../packages/tsconfig @@ -113,7 +116,7 @@ importers: version: 20.14.12 '@wagmi/cli': specifier: ^1.0.1 - version: 1.5.2(@wagmi/core@2.13.8(typescript@5.5.4))(typescript@5.5.4)(wagmi@2.12.17(typescript@5.5.4)(zod@3.23.8)) + version: 1.5.2(@wagmi/core@2.13.8(@tanstack/query-core@5.51.9)(@types/react@18.3.12)(react@18.3.1)(typescript@5.5.4)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8)))(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(wagmi@2.12.17(@tanstack/query-core@5.51.9)(@tanstack/react-query@5.51.11(react@18.3.1))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react-native@0.74.3(@babel/core@7.24.9)(@babel/preset-env@7.24.8(@babel/core@7.24.9))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(rollup@4.19.0)(typescript@5.5.4)(utf-8-validate@5.0.10)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8))(zod@3.23.8)) '@zoralabs/openzeppelin-contracts-upgradeable': specifier: 4.8.4 version: 4.8.4 @@ -241,11 +244,96 @@ importers: specifier: ^2.21.21 version: 2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8) + packages/comments: + dependencies: + abitype: + specifier: ^1.0.2 + version: 1.0.5(typescript@5.5.4)(zod@3.23.8) + dotenv: + specifier: ^16.4.5 + version: 16.4.5 + devDependencies: + '@openzeppelin/contracts': + specifier: 5.0.2 + version: 5.0.2 + '@openzeppelin/contracts-upgradeable': + specifier: 5.0.2 + version: 5.0.2(@openzeppelin/contracts@5.0.2) + '@turnkey/api-key-stamper': + specifier: ^0.3.1 + version: 0.3.1 + '@turnkey/http': + specifier: ^2.5.1 + version: 2.12.0(encoding@0.1.13) + '@turnkey/viem': + specifier: ^0.4.4 + version: 0.4.24(@babel/core@7.24.9)(@babel/preset-env@7.24.8(@babel/core@7.24.9))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)(utf-8-validate@5.0.10)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8)) + '@types/node': + specifier: ^20.1.2 + version: 20.14.12 + '@wagmi/cli': + specifier: ^1.0.1 + version: 1.5.2(@wagmi/core@2.13.8(@tanstack/query-core@5.51.9)(@types/react@18.3.12)(react@18.3.1)(typescript@5.5.4)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8)))(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(wagmi@2.12.17(@tanstack/query-core@5.51.9)(@tanstack/react-query@5.51.11(react@18.3.1))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react-native@0.74.3(@babel/core@7.24.9)(@babel/preset-env@7.24.8(@babel/core@7.24.9))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(rollup@4.19.0)(typescript@5.5.4)(utf-8-validate@5.0.10)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8))(zod@3.23.8)) + '@zoralabs/chains': + specifier: ^1.3.1 + version: 1.4.1(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8) + '@zoralabs/protocol-rewards': + specifier: workspace:^ + version: link:../protocol-rewards + '@zoralabs/shared-contracts': + specifier: workspace:^ + version: link:../shared-contracts + '@zoralabs/sparks-contracts': + specifier: workspace:^ + version: link:../sparks + '@zoralabs/tsconfig': + specifier: workspace:^ + version: link:../tsconfig + '@zoralabs/zora-1155-contracts': + specifier: workspace:^ + version: link:../1155-contracts + ds-test: + specifier: https://github.com/dapphub/ds-test#cd98eff28324bfac652e63a239a60632a761790b + version: https://codeload.github.com/dapphub/ds-test/tar.gz/cd98eff28324bfac652e63a239a60632a761790b + forge-std: + specifier: https://github.com/foundry-rs/forge-std#v1.9.1 + version: https://codeload.github.com/foundry-rs/forge-std/tar.gz/07263d193d621c4b2b0ce8b4d54af58f6957d97d + glob: + specifier: ^10.2.2 + version: 10.4.5 + pathe: + specifier: ^1.1.2 + version: 1.1.2 + prettier: + specifier: ^3.0.3 + version: 3.3.3 + prettier-plugin-solidity: + specifier: ^1.3.1 + version: 1.3.1(prettier@3.3.3) + solady: + specifier: 0.0.132 + version: 0.0.132 + tsup: + specifier: ^7.2.0 + version: 7.3.0(postcss@8.4.40)(ts-node@10.9.2(@types/node@20.14.12)(typescript@5.5.4))(typescript@5.5.4) + tsx: + specifier: ^3.13.0 + version: 3.14.0 + typescript: + specifier: ^5.2.2 + version: 5.5.4 + viem: + specifier: ^2.21.21 + version: 2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8) + packages/creator-subgraph: dependencies: '@graphprotocol/graph-ts': specifier: 0.29.3 version: 0.29.3 + '@zoralabs/comments-contracts': + specifier: workspace:^ + version: link:../comments '@zoralabs/erc20z': specifier: workspace:^ version: link:../erc20z @@ -303,7 +391,7 @@ importers: version: 20.14.12 '@wagmi/cli': specifier: ^1.0.1 - version: 1.5.2(@wagmi/core@2.13.8(@types/react@18.3.12)(react@18.3.1)(typescript@5.5.4)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8)))(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(wagmi@2.12.17(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)(typescript@5.5.4)(utf-8-validate@5.0.10)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8))(zod@3.23.8)) + version: 1.5.2(@wagmi/core@2.13.8(@tanstack/query-core@5.51.9)(@types/react@18.3.12)(react@18.3.1)(typescript@5.5.4)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8)))(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(wagmi@2.12.17(@tanstack/query-core@5.51.9)(@tanstack/react-query@5.51.11(react@18.3.1))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react-native@0.74.3(@babel/core@7.24.9)(@babel/preset-env@7.24.8(@babel/core@7.24.9))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(rollup@4.19.0)(typescript@5.5.4)(utf-8-validate@5.0.10)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8))(zod@3.23.8)) '@zoralabs/protocol-rewards': specifier: workspace:^ version: link:../protocol-rewards @@ -412,6 +500,9 @@ importers: '@zoralabs/1155-deployments': specifier: workspace:^ version: link:../1155-deployments + '@zoralabs/comments-contracts': + specifier: workspace:^ + version: link:../comments '@zoralabs/erc20z': specifier: workspace:^ version: link:../erc20z @@ -430,7 +521,7 @@ importers: version: 20.14.12 '@wagmi/cli': specifier: ^1.0.1 - version: 1.5.2(@wagmi/core@2.13.8(typescript@5.5.4)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8)))(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(wagmi@2.12.17(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8))(zod@3.23.8)) + version: 1.5.2(@wagmi/core@2.13.8(@tanstack/query-core@5.51.9)(@types/react@18.3.12)(react@18.3.1)(typescript@5.5.4)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8)))(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(wagmi@2.12.17(@tanstack/query-core@5.51.9)(@tanstack/react-query@5.51.11(react@18.3.1))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react-native@0.74.3(@babel/core@7.24.9)(@babel/preset-env@7.24.8(@babel/core@7.24.9))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(rollup@4.19.0)(typescript@5.5.4)(utf-8-validate@5.0.10)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8))(zod@3.23.8)) '@zoralabs/tsconfig': specifier: workspace:^ version: link:../tsconfig @@ -531,6 +622,10 @@ importers: version: 2.0.5(@types/node@20.14.12)(@vitest/ui@2.0.5)(terser@5.31.3) packages/shared-contracts: + dependencies: + '@openzeppelin/contracts-upgradeable': + specifier: ^5.1.0 + version: 5.1.0(@openzeppelin/contracts@5.0.2) devDependencies: '@openzeppelin/contracts': specifier: ^5.0.2 @@ -604,7 +699,7 @@ importers: version: 20.14.12 '@wagmi/cli': specifier: ^1.0.1 - version: 1.5.2(@wagmi/core@2.13.8(@types/react@18.3.12)(react@18.3.1)(typescript@5.5.4)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8)))(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(wagmi@2.12.17(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)(typescript@5.5.4)(utf-8-validate@5.0.10)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8))(zod@3.23.8)) + version: 1.5.2(@wagmi/core@2.13.8(@tanstack/query-core@5.51.9)(@types/react@18.3.12)(react@18.3.1)(typescript@5.5.4)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8)))(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(wagmi@2.12.17(@tanstack/query-core@5.51.9)(@tanstack/react-query@5.51.11(react@18.3.1))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react-native@0.74.3(@babel/core@7.24.9)(@babel/preset-env@7.24.8(@babel/core@7.24.9))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(rollup@4.19.0)(typescript@5.5.4)(utf-8-validate@5.0.10)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8))(zod@3.23.8)) '@zoralabs/shared-contracts': specifier: workspace:^ version: link:../shared-contracts @@ -2889,6 +2984,11 @@ packages: peerDependencies: '@openzeppelin/contracts': 5.0.2 + '@openzeppelin/contracts-upgradeable@5.1.0': + resolution: {integrity: sha512-AIElwP5Ck+cslNE+Hkemf5SxjJoF4wBvvjxc27Rp+9jaPs/CLIaUBMYe1FNzhdiN0cYuwGRmYaRHmmntuiju4Q==} + peerDependencies: + '@openzeppelin/contracts': 5.1.0 + '@openzeppelin/contracts@4.9.2': resolution: {integrity: sha512-mO+y6JaqXjWeMh9glYVzVu8HYPGknAAnWyxTRhGeckOruyXQMNnlcW6w/Dx9ftLeIQk6N+ZJFuVmTwF7lEIFrg==} @@ -3586,6 +3686,11 @@ packages: typescript: ^5.0.0 viem: ^2.21.21 + '@reservoir0x/relay-sdk@1.3.3': + resolution: {integrity: sha512-BL7TJOXsMb3NWd1vebdq9W4NBn4nKvha3aNo4WflQPeevBkm1Az4bePkkTdgvKhQVTckwaW4kCLozyaOeWQBKg==} + peerDependencies: + viem: ^2.21.21 + '@reservoir0x/reservoir-sdk@2.4.6': resolution: {integrity: sha512-qR7XJ1uwd4CdvjkcBT6IMyhp9lnNu9pQIQa2jPdr2T1Fzi/HelB8PQjB18ctTp23O5en6U7r2y18PHMJ/AkwwA==} peerDependencies: @@ -12430,22 +12535,7 @@ snapshots: '@metamask/safe-event-emitter@3.1.1': {} - '@metamask/sdk-communication-layer@0.28.2(cross-fetch@4.0.0)(eciesjs@0.3.19)(eventemitter2@6.4.9)(readable-stream@3.6.2)(socket.io-client@4.7.5(bufferutil@4.0.8)(utf-8-validate@5.0.10))': - dependencies: - bufferutil: 4.0.8 - cross-fetch: 4.0.0(encoding@0.1.13) - date-fns: 2.30.0 - debug: 4.3.5(supports-color@8.1.1) - eciesjs: 0.3.19 - eventemitter2: 6.4.9 - readable-stream: 3.6.2 - socket.io-client: 4.7.5(bufferutil@4.0.8)(utf-8-validate@5.0.10) - utf-8-validate: 5.0.10 - uuid: 8.3.2 - transitivePeerDependencies: - - supports-color - - '@metamask/sdk-communication-layer@0.28.2(cross-fetch@4.0.0)(eciesjs@0.3.19)(eventemitter2@6.4.9)(readable-stream@3.6.2)(socket.io-client@4.7.5)': + '@metamask/sdk-communication-layer@0.28.2(cross-fetch@4.0.0(encoding@0.1.13))(eciesjs@0.3.19)(eventemitter2@6.4.9)(readable-stream@3.6.2)(socket.io-client@4.7.5(bufferutil@4.0.8)(utf-8-validate@5.0.10))': dependencies: bufferutil: 4.0.8 cross-fetch: 4.0.0(encoding@0.1.13) @@ -12459,7 +12549,6 @@ snapshots: uuid: 8.3.2 transitivePeerDependencies: - supports-color - optional: true '@metamask/sdk-install-modal-web@0.28.1(i18next@23.11.5)(react-dom@18.3.1(react@18.3.1))(react-native@0.74.3(@babel/core@7.24.9)(@babel/preset-env@7.24.8(@babel/core@7.24.9))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)': dependencies: @@ -12470,45 +12559,11 @@ snapshots: react-dom: 18.3.1(react@18.3.1) react-native: 0.74.3(@babel/core@7.24.9)(@babel/preset-env@7.24.8(@babel/core@7.24.9))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)(utf-8-validate@5.0.10) - '@metamask/sdk@0.28.4': - dependencies: - '@metamask/onboarding': 1.0.1 - '@metamask/providers': 16.1.0 - '@metamask/sdk-communication-layer': 0.28.2(cross-fetch@4.0.0)(eciesjs@0.3.19)(eventemitter2@6.4.9)(readable-stream@3.6.2)(socket.io-client@4.7.5) - '@metamask/sdk-install-modal-web': 0.28.1(i18next@23.11.5)(react-dom@18.3.1(react@18.3.1))(react-native@0.74.3(@babel/core@7.24.9)(@babel/preset-env@7.24.8(@babel/core@7.24.9))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1) - '@types/dom-screen-wake-lock': 1.0.3 - '@types/uuid': 10.0.0 - bowser: 2.11.0 - cross-fetch: 4.0.0(encoding@0.1.13) - debug: 4.3.5(supports-color@8.1.1) - eciesjs: 0.3.19 - eth-rpc-errors: 4.0.3 - eventemitter2: 6.4.9 - i18next: 23.11.5 - i18next-browser-languagedetector: 7.1.0 - obj-multiplex: 1.0.0 - pump: 3.0.0 - qrcode-terminal-nooctal: 0.12.1 - react-native-webview: 11.26.1(react-native@0.74.3(@babel/core@7.24.9)(@babel/preset-env@7.24.8(@babel/core@7.24.9))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1) - readable-stream: 3.6.2 - rollup-plugin-visualizer: 5.12.0(rollup@4.19.0) - socket.io-client: 4.7.5(bufferutil@4.0.8)(utf-8-validate@5.0.10) - util: 0.12.5 - uuid: 8.3.2 - transitivePeerDependencies: - - bufferutil - - encoding - - react-native - - rollup - - supports-color - - utf-8-validate - optional: true - '@metamask/sdk@0.28.4(bufferutil@4.0.8)(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react-native@0.74.3(@babel/core@7.24.9)(@babel/preset-env@7.24.8(@babel/core@7.24.9))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(rollup@4.19.0)(utf-8-validate@5.0.10)': dependencies: '@metamask/onboarding': 1.0.1 '@metamask/providers': 16.1.0 - '@metamask/sdk-communication-layer': 0.28.2(cross-fetch@4.0.0)(eciesjs@0.3.19)(eventemitter2@6.4.9)(readable-stream@3.6.2)(socket.io-client@4.7.5(bufferutil@4.0.8)(utf-8-validate@5.0.10)) + '@metamask/sdk-communication-layer': 0.28.2(cross-fetch@4.0.0(encoding@0.1.13))(eciesjs@0.3.19)(eventemitter2@6.4.9)(readable-stream@3.6.2)(socket.io-client@4.7.5(bufferutil@4.0.8)(utf-8-validate@5.0.10)) '@metamask/sdk-install-modal-web': 0.28.1(i18next@23.11.5)(react-dom@18.3.1(react@18.3.1))(react-native@0.74.3(@babel/core@7.24.9)(@babel/preset-env@7.24.8(@babel/core@7.24.9))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1) '@types/dom-screen-wake-lock': 1.0.3 '@types/uuid': 10.0.0 @@ -12540,40 +12595,6 @@ snapshots: - supports-color - utf-8-validate - '@metamask/sdk@0.28.4(bufferutil@4.0.8)(utf-8-validate@5.0.10)': - dependencies: - '@metamask/onboarding': 1.0.1 - '@metamask/providers': 16.1.0 - '@metamask/sdk-communication-layer': 0.28.2(cross-fetch@4.0.0)(eciesjs@0.3.19)(eventemitter2@6.4.9)(readable-stream@3.6.2)(socket.io-client@4.7.5(bufferutil@4.0.8)(utf-8-validate@5.0.10)) - '@metamask/sdk-install-modal-web': 0.28.1(i18next@23.11.5)(react-dom@18.3.1(react@18.3.1))(react-native@0.74.3(@babel/core@7.24.9)(@babel/preset-env@7.24.8(@babel/core@7.24.9))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1) - '@types/dom-screen-wake-lock': 1.0.3 - '@types/uuid': 10.0.0 - bowser: 2.11.0 - cross-fetch: 4.0.0(encoding@0.1.13) - debug: 4.3.5(supports-color@8.1.1) - eciesjs: 0.3.19 - eth-rpc-errors: 4.0.3 - eventemitter2: 6.4.9 - i18next: 23.11.5 - i18next-browser-languagedetector: 7.1.0 - obj-multiplex: 1.0.0 - pump: 3.0.0 - qrcode-terminal-nooctal: 0.12.1 - react-native-webview: 11.26.1(react-native@0.74.3(@babel/core@7.24.9)(@babel/preset-env@7.24.8(@babel/core@7.24.9))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1) - readable-stream: 3.6.2 - rollup-plugin-visualizer: 5.12.0(rollup@4.19.0) - socket.io-client: 4.7.5(bufferutil@4.0.8)(utf-8-validate@5.0.10) - util: 0.12.5 - uuid: 8.3.2 - transitivePeerDependencies: - - bufferutil - - encoding - - react-native - - rollup - - supports-color - - utf-8-validate - optional: true - '@metamask/superstruct@3.1.0': {} '@metamask/utils@5.0.2': @@ -12848,6 +12869,10 @@ snapshots: dependencies: '@openzeppelin/contracts': 5.0.2 + '@openzeppelin/contracts-upgradeable@5.1.0(@openzeppelin/contracts@5.0.2)': + dependencies: + '@openzeppelin/contracts': 5.0.2 + '@openzeppelin/contracts@4.9.2': {} '@openzeppelin/contracts@5.0.2': {} @@ -13868,6 +13893,13 @@ snapshots: transitivePeerDependencies: - debug + '@reservoir0x/relay-sdk@1.3.3(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8))': + dependencies: + axios: 1.7.2 + viem: 2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8) + transitivePeerDependencies: + - debug + '@reservoir0x/reservoir-sdk@2.4.6(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8))': dependencies: axios: 1.7.2 @@ -14593,7 +14625,7 @@ snapshots: loupe: 3.1.1 tinyrainbow: 1.2.0 - '@wagmi/cli@1.5.2(@wagmi/core@2.13.8(@types/react@18.3.12)(react@18.3.1)(typescript@5.5.4)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8)))(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(wagmi@2.12.17(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)(typescript@5.5.4)(utf-8-validate@5.0.10)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8))(zod@3.23.8))': + '@wagmi/cli@1.5.2(@wagmi/core@2.13.8(@tanstack/query-core@5.51.9)(@types/react@18.3.12)(react@18.3.1)(typescript@5.5.4)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8)))(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(wagmi@2.12.17(@tanstack/query-core@5.51.9)(@tanstack/react-query@5.51.11(react@18.3.1))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react-native@0.74.3(@babel/core@7.24.9)(@babel/preset-env@7.24.8(@babel/core@7.24.9))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(rollup@4.19.0)(typescript@5.5.4)(utf-8-validate@5.0.10)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8))(zod@3.23.8))': dependencies: abitype: 0.8.7(typescript@5.5.4)(zod@3.23.8) abort-controller: 3.0.0 @@ -14625,70 +14657,6 @@ snapshots: - bufferutil - utf-8-validate - '@wagmi/cli@1.5.2(@wagmi/core@2.13.8(typescript@5.5.4)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8)))(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(wagmi@2.12.17(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8))(zod@3.23.8))': - dependencies: - abitype: 0.8.7(typescript@5.5.4)(zod@3.23.8) - abort-controller: 3.0.0 - bundle-require: 3.1.2(esbuild@0.16.17) - cac: 6.7.14 - change-case: 4.1.2 - chokidar: 3.6.0 - dedent: 0.7.0 - detect-package-manager: 2.0.1 - dotenv: 16.4.5 - dotenv-expand: 10.0.0 - esbuild: 0.16.17 - execa: 6.1.0 - find-up: 6.3.0 - fs-extra: 10.1.0 - globby: 13.2.2 - node-fetch: 3.3.2 - ora: 6.3.1 - pathe: 1.1.2 - picocolors: 1.0.1 - prettier: 2.8.8 - viem: 2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8) - zod: 3.23.8 - optionalDependencies: - '@wagmi/core': 2.13.8(typescript@5.5.4)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8)) - typescript: 5.5.4 - wagmi: 2.12.17(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8))(zod@3.23.8) - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - '@wagmi/cli@1.5.2(@wagmi/core@2.13.8(typescript@5.5.4))(typescript@5.5.4)(wagmi@2.12.17(typescript@5.5.4)(zod@3.23.8))': - dependencies: - abitype: 0.8.7(typescript@5.5.4)(zod@3.23.8) - abort-controller: 3.0.0 - bundle-require: 3.1.2(esbuild@0.16.17) - cac: 6.7.14 - change-case: 4.1.2 - chokidar: 3.6.0 - dedent: 0.7.0 - detect-package-manager: 2.0.1 - dotenv: 16.4.5 - dotenv-expand: 10.0.0 - esbuild: 0.16.17 - execa: 6.1.0 - find-up: 6.3.0 - fs-extra: 10.1.0 - globby: 13.2.2 - node-fetch: 3.3.2 - ora: 6.3.1 - pathe: 1.1.2 - picocolors: 1.0.1 - prettier: 2.8.8 - viem: 2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8) - zod: 3.23.8 - optionalDependencies: - '@wagmi/core': 2.13.8(typescript@5.5.4)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8)) - typescript: 5.5.4 - wagmi: 2.12.17(typescript@5.5.4)(zod@3.23.8) - transitivePeerDependencies: - - bufferutil - - utf-8-validate - '@wagmi/connectors@5.1.15(@types/react@18.3.12)(@wagmi/core@2.13.8(@tanstack/query-core@5.51.9)(@types/react@18.3.12)(react@18.3.1)(typescript@5.5.4)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8)))(bufferutil@4.0.8)(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react-native@0.74.3(@babel/core@7.24.9)(@babel/preset-env@7.24.8(@babel/core@7.24.9))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(rollup@4.19.0)(typescript@5.5.4)(utf-8-validate@5.0.10)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8))(zod@3.23.8)': dependencies: '@coinbase/wallet-sdk': 4.0.4 @@ -14728,85 +14696,6 @@ snapshots: - utf-8-validate - zod - '@wagmi/connectors@5.1.15(@wagmi/core@2.13.8(typescript@5.5.4)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8)))(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8))(zod@3.23.8)': - dependencies: - '@coinbase/wallet-sdk': 4.0.4 - '@metamask/sdk': 0.28.4(bufferutil@4.0.8)(utf-8-validate@5.0.10) - '@safe-global/safe-apps-provider': 0.18.3(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8) - '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8) - '@wagmi/core': 2.13.8(typescript@5.5.4)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8)) - '@walletconnect/ethereum-provider': 2.17.0(bufferutil@4.0.8)(utf-8-validate@5.0.10) - '@walletconnect/modal': 2.7.0(@types/react@18.3.12)(react@18.3.1) - cbw-sdk: '@coinbase/wallet-sdk@3.9.3' - viem: 2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8) - optionalDependencies: - typescript: 5.5.4 - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@capacitor/preferences' - - '@netlify/blobs' - - '@planetscale/database' - - '@react-native-async-storage/async-storage' - - '@types/react' - - '@upstash/redis' - - '@vercel/kv' - - bufferutil - - encoding - - ioredis - - react - - react-dom - - react-native - - rollup - - supports-color - - uWebSockets.js - - utf-8-validate - - zod - optional: true - - '@wagmi/connectors@5.1.15(@wagmi/core@2.13.8(typescript@5.5.4))(typescript@5.5.4)(zod@3.23.8)': - dependencies: - '@coinbase/wallet-sdk': 4.0.4 - '@metamask/sdk': 0.28.4 - '@safe-global/safe-apps-provider': 0.18.3(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8) - '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8) - '@wagmi/core': 2.13.8(typescript@5.5.4)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8)) - '@walletconnect/ethereum-provider': 2.17.0 - '@walletconnect/modal': 2.7.0(@types/react@18.3.12)(react@18.3.1) - cbw-sdk: '@coinbase/wallet-sdk@3.9.3' - optionalDependencies: - typescript: 5.5.4 - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@capacitor/preferences' - - '@netlify/blobs' - - '@planetscale/database' - - '@react-native-async-storage/async-storage' - - '@types/react' - - '@upstash/redis' - - '@vercel/kv' - - bufferutil - - encoding - - ioredis - - react - - react-dom - - react-native - - rollup - - supports-color - - uWebSockets.js - - utf-8-validate - - zod - optional: true - '@wagmi/core@2.13.8(@tanstack/query-core@5.51.9)(@types/react@18.3.12)(react@18.3.1)(typescript@5.5.4)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8))': dependencies: eventemitter3: 5.0.1 @@ -14821,20 +14710,6 @@ snapshots: - immer - react - '@wagmi/core@2.13.8(typescript@5.5.4)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8))': - dependencies: - eventemitter3: 5.0.1 - mipd: 0.0.7(typescript@5.5.4) - viem: 2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8) - zustand: 4.4.1(@types/react@18.3.12)(react@18.3.1) - optionalDependencies: - typescript: 5.5.4 - transitivePeerDependencies: - - '@types/react' - - immer - - react - optional: true - '@walletconnect/core@2.17.0(bufferutil@4.0.8)(utf-8-validate@5.0.10)': dependencies: '@walletconnect/heartbeat': 1.2.2 @@ -14875,40 +14750,6 @@ snapshots: dependencies: tslib: 1.14.1 - '@walletconnect/ethereum-provider@2.17.0': - dependencies: - '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) - '@walletconnect/jsonrpc-provider': 1.0.14 - '@walletconnect/jsonrpc-types': 1.0.4 - '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/modal': 2.7.0(@types/react@18.3.12)(react@18.3.1) - '@walletconnect/sign-client': 2.17.0(bufferutil@4.0.8)(utf-8-validate@5.0.10) - '@walletconnect/types': 2.17.0 - '@walletconnect/universal-provider': 2.17.0 - '@walletconnect/utils': 2.17.0 - events: 3.3.0 - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@capacitor/preferences' - - '@netlify/blobs' - - '@planetscale/database' - - '@react-native-async-storage/async-storage' - - '@types/react' - - '@upstash/redis' - - '@vercel/kv' - - bufferutil - - encoding - - ioredis - - react - - uWebSockets.js - - utf-8-validate - optional: true - '@walletconnect/ethereum-provider@2.17.0(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)(utf-8-validate@5.0.10)': dependencies: '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) @@ -14942,40 +14783,6 @@ snapshots: - uWebSockets.js - utf-8-validate - '@walletconnect/ethereum-provider@2.17.0(bufferutil@4.0.8)(utf-8-validate@5.0.10)': - dependencies: - '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) - '@walletconnect/jsonrpc-provider': 1.0.14 - '@walletconnect/jsonrpc-types': 1.0.4 - '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/modal': 2.7.0(@types/react@18.3.12)(react@18.3.1) - '@walletconnect/sign-client': 2.17.0(bufferutil@4.0.8)(utf-8-validate@5.0.10) - '@walletconnect/types': 2.17.0 - '@walletconnect/universal-provider': 2.17.0(bufferutil@4.0.8)(encoding@0.1.13)(utf-8-validate@5.0.10) - '@walletconnect/utils': 2.17.0 - events: 3.3.0 - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@capacitor/preferences' - - '@netlify/blobs' - - '@planetscale/database' - - '@react-native-async-storage/async-storage' - - '@types/react' - - '@upstash/redis' - - '@vercel/kv' - - bufferutil - - encoding - - ioredis - - react - - uWebSockets.js - - utf-8-validate - optional: true - '@walletconnect/events@1.0.1': dependencies: keyvaluestorage-interface: 1.0.0 @@ -15147,37 +14954,6 @@ snapshots: - ioredis - uWebSockets.js - '@walletconnect/universal-provider@2.17.0': - dependencies: - '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) - '@walletconnect/jsonrpc-provider': 1.0.14 - '@walletconnect/jsonrpc-types': 1.0.4 - '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.17.0(bufferutil@4.0.8)(utf-8-validate@5.0.10) - '@walletconnect/types': 2.17.0 - '@walletconnect/utils': 2.17.0 - events: 3.3.0 - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@capacitor/preferences' - - '@netlify/blobs' - - '@planetscale/database' - - '@react-native-async-storage/async-storage' - - '@upstash/redis' - - '@vercel/kv' - - bufferutil - - encoding - - ioredis - - uWebSockets.js - - utf-8-validate - optional: true - '@walletconnect/universal-provider@2.17.0(bufferutil@4.0.8)(encoding@0.1.13)(utf-8-validate@5.0.10)': dependencies: '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) @@ -18168,7 +17944,7 @@ snapshots: dependencies: ws: 7.5.10(bufferutil@4.0.8)(utf-8-validate@5.0.10) - isows@1.0.6(ws@8.18.0): + isows@1.0.6(ws@8.18.0(bufferutil@4.0.8)(utf-8-validate@5.0.10)): dependencies: ws: 8.18.0(bufferutil@4.0.8)(utf-8-validate@5.0.10) @@ -21865,7 +21641,7 @@ snapshots: '@scure/bip32': 1.5.0 '@scure/bip39': 1.4.0 abitype: 1.0.6(typescript@5.5.4)(zod@3.23.8) - isows: 1.0.6(ws@8.18.0) + isows: 1.0.6(ws@8.18.0(bufferutil@4.0.8)(utf-8-validate@5.0.10)) webauthn-p256: 0.0.10 ws: 8.18.0(bufferutil@4.0.8)(utf-8-validate@5.0.10) optionalDependencies: @@ -22113,77 +21889,6 @@ snapshots: - utf-8-validate - zod - wagmi@2.12.17(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8))(zod@3.23.8): - dependencies: - '@wagmi/connectors': 5.1.15(@wagmi/core@2.13.8(typescript@5.5.4)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8)))(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8))(zod@3.23.8) - '@wagmi/core': 2.13.8(typescript@5.5.4)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8)) - use-sync-external-store: 1.2.0(react@18.3.1) - viem: 2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8) - optionalDependencies: - typescript: 5.5.4 - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@capacitor/preferences' - - '@netlify/blobs' - - '@planetscale/database' - - '@react-native-async-storage/async-storage' - - '@tanstack/query-core' - - '@types/react' - - '@upstash/redis' - - '@vercel/kv' - - bufferutil - - encoding - - immer - - ioredis - - react-dom - - react-native - - rollup - - supports-color - - uWebSockets.js - - utf-8-validate - - zod - optional: true - - wagmi@2.12.17(typescript@5.5.4)(zod@3.23.8): - dependencies: - '@wagmi/connectors': 5.1.15(@wagmi/core@2.13.8(typescript@5.5.4))(typescript@5.5.4)(zod@3.23.8) - '@wagmi/core': 2.13.8(typescript@5.5.4)(viem@2.21.21(bufferutil@4.0.8)(typescript@5.5.4)(utf-8-validate@5.0.10)(zod@3.23.8)) - use-sync-external-store: 1.2.0(react@18.3.1) - optionalDependencies: - typescript: 5.5.4 - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@capacitor/preferences' - - '@netlify/blobs' - - '@planetscale/database' - - '@react-native-async-storage/async-storage' - - '@tanstack/query-core' - - '@types/react' - - '@upstash/redis' - - '@vercel/kv' - - bufferutil - - encoding - - immer - - ioredis - - react-dom - - react-native - - rollup - - supports-color - - uWebSockets.js - - utf-8-validate - - zod - optional: true - walker@1.0.8: dependencies: makeerror: 1.0.12