Skip to content

Conversation

@wojciech-turek
Copy link
Member

@wojciech-turek wojciech-turek commented Sep 3, 2025

Description

Summary by CodeRabbit

  • New Features

    • Purchase-agent–mediated wave minting allowing buyers to direct NFTs to a specified final recipient.
    • Admin bulk authorization for NFT collections and support for purchase flows that include a local purchase token ID.
  • Tests

    • Expanded coverage for multi-recipient purchases, per-wallet limits, authorization changes, and error paths.
  • Chores

    • Updated deployment/config addresses for deployer and purchase wallet.

@wojciech-turek wojciech-turek requested a review from a team as a code owner September 3, 2025 11:54
@coderabbitai
Copy link

coderabbitai bot commented Sep 3, 2025

Walkthrough

Adds purchase-agent–mediated minting: INFTCollection gains NotPurchaseAgent and wrappedWaveMint; NFTCollection implements wrappedWaveMint with purchaseAgent checks and payment flow; PurchaseWrapper threads a finalReceiver into confirmPurchase and calls wrappedWaveMint; tests, deployment, and Hardhat named accounts updated accordingly.

Changes

Cohort / File(s) Summary
Interface
packages/avatar/contracts/nft-collection/INFTCollection.sol
Added error NotPurchaseAgent(address) and function wrappedWaveMint(address caller, address destinationWallet, uint256 amount, uint256 waveIndex, uint256 signatureId, bytes calldata signature) external returns (uint256[] memory).
Collection implementation
packages/avatar/contracts/nft-collection/NFTCollection.sol
Implemented wrappedWaveMint with purchaseAgent authorization checks, wave/signature/limit validation, price calculation and transfer to treasury, counter updates, token minting, and WaveMint events; returns minted IDs.
Purchase wrapper
packages/avatar/contracts/nft-collection/PurchaseWrapper.sol
confirmPurchase gains finalReceiver param; _initiateMintViaApproveAndCall threaded to accept finalReceiver; calls changed from waveMint to wrappedWaveMint (ABI updated); added batchSetNftCollectionAuthorization; validates non-zero finalReceiver.
Tests & fixtures
packages/avatar/test/avatar/fixtures.ts, packages/avatar/test/avatar/nft-collection/PurchaseWrapper.test.ts
Added randomWallet3 fixture, register PurchaseWrapper as purchaseAgent in setup, updated tests to use new confirmPurchase overload(s), multi-recipient scenarios, and new error-path assertions (local token ID reuse, per-wallet limits, de-authorization).
Deploy script
packages/deploy/deploy/28_nft_collection/04_deploy_purchase_wrapper.ts
Replaced placeholder Transak wallet with concrete address in non-amoy/hardhat branch.
Hardhat config
packages/deploy/hardhat.config.ts
Consolidated namedAccounts.deployer addresses across networks to specified mappings.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor User
  participant Wrapper as PurchaseWrapper
  participant ERC20 as SAND Token
  participant Collection as NFTCollection
  participant Treasury as Mint Treasury

  User->>Wrapper: confirmPurchase(sender, finalReceiver, collection, waveIndex, signatureId, localTokenId?, signature)
  Wrapper->>ERC20: transferFrom(sender -> Wrapper, price)
  alt price > 0
    ERC20-->>Wrapper: funds received
  end
  note over Wrapper,Collection: Wrapper is configured as purchaseAgent on Collection
  Wrapper->>Collection: wrappedWaveMint(caller=Wrapper, destinationWallet=finalReceiver, amount=1, waveIndex, signatureId, signature)
  Collection->>Collection: validate purchaseAgent, wave config, signer, per-wallet limits
  Collection->>Treasury: transfer price (if applicable)
  Collection->>Collection: update counters, totalSupply, mint tokens
  Collection-->>Wrapper: return minted token IDs
  Wrapper-->>User: emit PurchaseConfirmed, proceed with transfer/delivery
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • atkinsonholly

Pre-merge checks (1 passed, 1 warning, 1 inconclusive)

❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
Description Check ❓ Inconclusive The pull request description consists solely of an empty header without any meaningful details about the changes introduced, leaving its purpose and scope unclear to reviewers. Please add a comprehensive description outlining the motivation, key changes, and intended behavior of this pull request to help reviewers understand its scope and verify correctness.
✅ Passed checks (1 passed)
Check name Status Explanation
Title Check ✅ Passed The title accurately and concisely captures the primary change of the pull request by noting that the final recipient is now passed into the NFTCollection contract to enable correct per-wallet limit calculations, making it clear and specific enough for reviewers to understand the main purpose without extraneous details.

Poem

I’m a rabbit with a ledger bright,
Wrapped mints hop in through the night.
Agent nods, signatures sing,
Final wallets get their bling.
Tests and configs in a line — mint carrots, all is fine! 🥕

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title accurately and concisely describes the primary change: passing a final recipient (finalReceiver) through the PurchaseWrapper into NFTCollection so per-wallet limit calculations are correct. It is specific, on-topic, and clear for reviewers scanning the history.
Docstring Coverage ✅ Passed No functions found in the changes. Docstring coverage check skipped.
✨ Finishing touches
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch purchase-wrapper-fix

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between cf23321 and 29c7f58.

📒 Files selected for processing (1)
  • packages/avatar/contracts/nft-collection/PurchaseWrapper.sol (4 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/avatar/contracts/nft-collection/PurchaseWrapper.sol
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (6)
  • GitHub Check: deploy
  • GitHub Check: lint
  • GitHub Check: analyze
  • GitHub Check: format
  • GitHub Check: coverage
  • GitHub Check: test

Tip

👮 Agentic pre-merge checks are now available in preview!

Pro plan users can now enable pre-merge checks in their settings to enforce checklists before merging PRs.

  • Built-in checks – Quickly apply ready-made checks to enforce title conventions, require pull request descriptions that follow templates, validate linked issues for compliance, and more.
  • Custom agentic checks – Define your own rules using CodeRabbit’s advanced agentic capabilities to enforce organization-specific policies and workflows. For example, you can instruct CodeRabbit’s agent to verify that API documentation is updated whenever API schema files are modified in a PR. Note: Upto 5 custom checks are currently allowed during the preview period. Pricing for this feature will be announced in a few weeks.

Please see the documentation for more information.

Example:

reviews:
  pre_merge_checks:
    custom_checks:
      - name: "Undocumented Breaking Changes"
        mode: "warning"
        instructions: |
          Pass/fail criteria: All breaking changes to public APIs, CLI flags, environment variables, configuration keys, database schemas, or HTTP/GraphQL endpoints must be documented in the "Breaking Change" section of the PR description and in CHANGELOG.md. Exclude purely internal or private changes (e.g., code not exported from package entry points or explicitly marked as internal).

Please share your feedback with us on this Discord post.


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (10)
packages/deploy/hardhat.config.ts (1)

105-113: Verify deployer EOAs and consider centralizing via env/named constants

These are production-impacting EOAs. Please confirm ownership/rotation plans and that they’re correct for each network. Consider moving the repeated addresses to env-driven constants (or a small mapping) to avoid future drift.

packages/avatar/contracts/nft-collection/NFTCollection.sol (1)

298-303: Optional: emit destination wallet for observability

WaveMint logs caller only. Consider adding a dedicated event (or extending the existing one) to include destinationWallet for off-chain reconciliation.

packages/avatar/contracts/nft-collection/INFTCollection.sol (1)

359-376: Clarify who actually receives the minted token and how auth is enforced

Docs say “to a destination wallet,” while PurchaseWrapper’s call/comment suggests tokens mint to the wrapper (caller) and only limits are accounted against destinationWallet. Also, since calls come via SAND.approveAndCall, msg.sender is the SAND token; authorization likely checks that:

  • msg.sender == allowedToExecuteMint
  • caller == purchaseAgent

Recommend tightening NatSpec to avoid ambiguity.

Apply doc-only diff:

- * @notice Mints a token for a specific wave to a destination wallet.
- * @dev Can only be called by the purchaseAgent.
+ * @notice Mints for a specific wave while accounting per-wallet limits against `destinationWallet`.
+ * @dev Must be invoked by the payment token via approveAndCall (i.e., `msg.sender == allowedToExecuteMint`),
+ *      and `caller` must equal the configured `purchaseAgent`.
+ *      Implementation mints the token(s) to `caller` (the purchase agent), while quota/limit checks
+ *      are performed against `destinationWallet`.
packages/avatar/contracts/nft-collection/PurchaseWrapper.sol (4)

153-160: Consider emitting finalReceiver for traceability

PurchaseConfirmed currently omits finalReceiver. Emitting it would help correlate quota accounting vs. actual recipient of the NFT.

If desired, extend the event and emit it here. I can draft the minimal ABI-safe upgrade (new event) if you want.


243-261: Align comment with actual semantics

The inline comment “NFTs will be minted to this contract first” is the critical behavior enabling post-mint transfer to the buyer. Keep this comment, but consider adding that destinationWallet is used for limit checks in the collection, not as the mint recipient.

-                address(this), // NFTs will be minted to this contract first
+                address(this), // NFTs mint to the wrapper; collection uses `finalReceiver` for per-wallet limits
                 finalReceiver,

271-293: Prefer abi.decode over manual offset parsing for nested return data

Manual offsets are brittle to encoding changes. Decode the nested bytes safely.

Apply this diff:

-        uint256[] memory tokenIds = new uint256[](1);
-        // The return data from `approveAndCall` is a `bytes` type, which means the actual return data from `waveMint` (an abi-encoded uint256[])
-        // is itself abi-encoded. We need to go deeper.
-        // `result` raw data layout:
-        // - 0x00: offset to bytes data (0x20)
-        // - 0x20: length of bytes data (e.g., 96 for a single uint256 in an array)
-        // - 0x40: start of the `waveMint` return data
-        //   - 0x40: offset to array data (0x20)
-        //   - 0x60: array length (1)
-        //   - 0x80: the token ID
-        if (result.length < 160) {
-            revert PurchaseWrapperNftPurchaseFailedViaApproveAndCall();
-        }
-        bytes32 tokenIdWord;
-        assembly {
-            // We read the word at offset 0x80 in the raw return data.
-            // The `result` variable is a memory pointer, and its data starts at an offset of 0x20.
-            // So we read from result + 0x20 (start of data) + 0x80 (offset to tokenId) = result + 0xa0
-            tokenIdWord := mload(add(result, 0xa0))
-        }
-        tokenIds[0] = uint256(tokenIdWord);
-        return tokenIds[0];
+        // `approveAndCall` returns bytes; inner payload is the collection's abi-encoded uint256[].
+        bytes memory inner = abi.decode(result, (bytes));
+        uint256[] memory tokenIds = abi.decode(inner, (uint256[]));
+        if (tokenIds.length == 0) revert PurchaseWrapperNftPurchaseFailedViaApproveAndCall();
+        return tokenIds[0];

300-301: Misleading error payload: it reports the EOA ‘sender’, not msg.sender

The revert passes sender into PurchaseWrapperCallerNotAuthorized(address), which reads as the caller not authorized, but the caller is msg.sender (SAND or a direct EOA). Consider either renaming the error or including both addresses for clarity.

Example:

-error PurchaseWrapperCallerNotAuthorized(address caller);
+error PurchaseWrapperCallerNotAuthorized(address sandMsgSender, address originalSender);
...
-  revert PurchaseWrapperCallerNotAuthorized(sender);
+  revert PurchaseWrapperCallerNotAuthorized(msg.sender, sender);
packages/avatar/test/avatar/nft-collection/PurchaseWrapper.test.ts (3)

114-123: Nice coverage of finalReceiver threading; add an assertion once to prove quota is tracked against it

You already assert quota in the multi-recipient test. For completeness, add a single check (e.g., after the first confirmPurchase) that waveOwnerToClaimedCounts(waveIndex, finalReceiver) == 1.

If helpful, I can draft a minimal assertion snippet to drop into the first test.

Also applies to: 145-155, 209-210, 234-245, 286-294, 543-554, 673-674


326-333: Fix typos and stale comments in the multi-recipient test

  • Title: “ming” -> “mint”.
  • The “Second Purchase (should fail)” comment contradicts the expectation; it should say “should succeed”.

Apply this diff:

-it('should allow two different users to ming through purchase wrapper when max tokens per wallet is set to 1', async function () {
+it('should allow two different users to mint through purchase wrapper when max tokens per wallet is set to 1', async function () {
...
-      // --- Second Purchase (should fail) ---
+      // --- Second Purchase (should succeed) ---

Also applies to: 381-407


520-522: Add a test for zero-address finalReceiver once wrapper enforces it

After adding the zero-address guard, include a revert test to lock it in.

Example:

+    it('should revert when finalReceiver is zero address', async function () {
+      const {collectionContractAddress, sandContract, randomWallet: userA, purchaseWrapper, purchaseWrapperAddress} =
+        await loadFixture(setupPurchaseWrapperFixture);
+      const sandPrice = ethers.parseEther('100');
+      const waveIndex = 0;
+      const signatureId = 999;
+      const randomTempTokenId = 888;
+      const userAAddress = await userA.getAddress();
+      await sandContract.donateTo(userAAddress, sandPrice);
+      await sandContract.connect(userA).approve(purchaseWrapperAddress, sandPrice);
+      const signature = '0x';
+      const data = purchaseWrapper.interface.encodeFunctionData('confirmPurchase', [
+        userAAddress,
+        ethers.ZeroAddress,
+        collectionContractAddress,
+        waveIndex,
+        signatureId,
+        randomTempTokenId,
+        signature
+      ]);
+      await expect(
+        sandContract.connect(userA).approveAndCall(purchaseWrapperAddress, sandPrice, data)
+      ).to.be.revertedWithCustomError(purchaseWrapper, 'PurchaseWrapperInvalidRecipientAddress');
+    });

Also applies to: 544-554

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between cef77fc and fab48d0.

📒 Files selected for processing (7)
  • packages/avatar/contracts/nft-collection/INFTCollection.sol (2 hunks)
  • packages/avatar/contracts/nft-collection/NFTCollection.sol (1 hunks)
  • packages/avatar/contracts/nft-collection/PurchaseWrapper.sol (3 hunks)
  • packages/avatar/test/avatar/fixtures.ts (3 hunks)
  • packages/avatar/test/avatar/nft-collection/PurchaseWrapper.test.ts (22 hunks)
  • packages/deploy/deploy/28_nft_collection/04_deploy_purchase_wrapper.ts (1 hunks)
  • packages/deploy/hardhat.config.ts (1 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (6)
  • GitHub Check: deploy
  • GitHub Check: analyze
  • GitHub Check: coverage
  • GitHub Check: lint
  • GitHub Check: format
  • GitHub Check: test
🔇 Additional comments (8)
packages/avatar/test/avatar/fixtures.ts (2)

3-5: LGTM: ethers imports consolidated for v6

Imports are correct and consistent with usage (parseUnits BigInt).


49-64: LGTM: exposing randomWallet3

Keeps fixtures aligned with multi-recipient tests.

packages/avatar/contracts/nft-collection/NFTCollection.sol (3)

275-277: Tighten auth errors and split checks for clarity

Return precise errors and avoid misreporting token as “not purchase agent”.

[ suggest_essential_refactor ]
Apply this diff:

-        if (_msgSender() != address($.allowedToExecuteMint) || caller != $.purchaseAgent) {
-            revert NotPurchaseAgent(_msgSender());
-        }
+        if (_msgSender() != address($.allowedToExecuteMint)) {
+            revert ERC721InvalidSender(_msgSender());
+        }
+        if (caller != $.purchaseAgent) {
+            revert NotPurchaseAgent(caller);
+        }

289-301: Ensure mint recipient aligns with accounting wallet
wrappedWaveMint mints to caller but updates counts under destinationWallet, and PurchaseWrapper.confirmPurchase calls transferFrom(address(this), sender, tokenId), delivering the NFT to sender instead of the finalReceiver argument. If the NFT should land at finalReceiver, update the transfer to use finalReceiver and add a test asserting its ownership.


281-283: Incorrect — signature already binds destination wallet

waveMint calls _checkAndSetWaveMintSignature(wallet, waveIndex, signatureId, signature) and NFTCollectionSignature._getWaveMintSignature includes the wallet in the signed payload; the proposed caller→destinationWallet diff is unnecessary.

Likely an incorrect or invalid review comment.

packages/avatar/contracts/nft-collection/INFTCollection.sol (1)

322-327: Good addition: explicit error for purchase-agent violations

The dedicated NotPurchaseAgent(address) error is clear and helps distinguish auth failures from other errors. No issues.

packages/avatar/test/avatar/nft-collection/PurchaseWrapper.test.ts (2)

32-35: Required wiring: setting PurchaseAgent to the wrapper looks correct

This mirrors the on-chain requirement for the wrappedWaveMint path. Good.


571-654: Good negative path: enforcing per-wallet max via finalReceiver

This validates that using the same finalReceiver twice reverts via the wrapper surface. Looks good.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
packages/avatar/contracts/nft-collection/PurchaseWrapper.sol (3)

13-20: Docs: “Uses Ownable” is incorrect; contract uses AccessControl

Update header doc to match implementation.


296-304: Authorization gate is wrong: confirmPurchase will always revert unless called by SAND

Currently requires msg.sender == SAND and ‘sender’ to hold the role; confirmPurchase is invoked by a purchase agent/backend, not SAND.

Apply:

-        if (msg.sender != address(sandToken) || !hasRole(AUTHORIZED_CALLER_ROLE, sender)) {
-            revert PurchaseWrapperCallerNotAuthorized(sender);
-        }
+        if (!hasRole(AUTHORIZED_CALLER_ROLE, msg.sender)) {
+            revert PurchaseWrapperCallerNotAuthorized(msg.sender);
+        }

308-311: TOCTOU on localTokenId: collision possible before mint completes

Only checking nftTokenId leaves a window where a second call can reuse the same ID before the first mints.

Apply:

-        if (_purchaseInfo[randomTempTokenId].nftTokenId != 0) {
+        PurchaseInfo memory existing = _purchaseInfo[randomTempTokenId];
+        if (existing.caller != address(0) || existing.nftTokenId != 0) {
             revert PurchaseWrapperLocalTokenIdAlreadyInUse(randomTempTokenId);
         }
♻️ Duplicate comments (1)
packages/avatar/contracts/nft-collection/PurchaseWrapper.sol (1)

142-142: Good: zero-address validation for finalReceiver

This addresses the earlier review about zero-address recipients.

🧹 Nitpick comments (4)
packages/avatar/contracts/nft-collection/PurchaseWrapper.sol (4)

118-124: Docs mention _txContext and onERC721Received flow that no longer exist

Prune outdated references; describe the current approveAndCall → wrappedWaveMint path and post-call transfer.


132-140: Guard sender != address(0)

Prevent storing a zero “original sender”.

Apply:

 function confirmPurchase(
   address sender,
   address finalReceiver,
   address nftCollection,
   uint256 waveIndex,
   uint256 signatureId,
   uint256 randomTempTokenId,
   bytes calldata signature
 ) external nonReentrant {
+    if (sender == address(0)) revert PurchaseWrapperInvalidRecipientAddress();

253-262: Prefer abi.decode over manual length check + assembly for nested return

Safer and clearer.

Apply:

-        uint256[] memory tokenIds = new uint256[](1);
-        if (result.length < 160) {
-            revert PurchaseWrapperNftPurchaseFailedViaApproveAndCall();
-        }
-        bytes32 tokenIdWord;
-        assembly {
-            tokenIdWord := mload(add(result, 0xa0))
-        }
-        tokenIds[0] = uint256(tokenIdWord);
-        return tokenIds[0];
+        bytes memory inner = abi.decode(result, (bytes));
+        uint256[] memory tokenIds = abi.decode(inner, (uint256[]));
+        if (tokenIds.length != 1) revert PurchaseWrapperNftPurchaseFailedViaApproveAndCall();
+        return tokenIds[0];

Also applies to: 272-293


61-74: Event lacks finalReceiver (useful for auditing per-wallet limits)

Consider indexing finalReceiver in PurchaseConfirmed for traceability.

I can propose an ABI-safe event change if backward-compat isn’t required, or a new event otherwise.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between fab48d0 and cf23321.

📒 Files selected for processing (1)
  • packages/avatar/contracts/nft-collection/PurchaseWrapper.sol (3 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (6)
  • GitHub Check: lint
  • GitHub Check: test
  • GitHub Check: analyze
  • GitHub Check: format
  • GitHub Check: coverage
  • GitHub Check: deploy
🔇 Additional comments (5)
packages/avatar/contracts/nft-collection/PurchaseWrapper.sol (5)

153-161: Plumbing finalReceiver through mint path looks correct

Argument order and usage align with the new API.


164-167: Confirm intended recipient: transfer goes to sender, not finalReceiver

If finalReceiver is only for quota calculation, rename for clarity (e.g., limitWallet). If it should be the recipient, change this transfer.

Would you like a patch to deliver to finalReceiver and persist it in PurchaseInfo?


245-251: Signature change carried through correctly

Private helper accepts finalReceiver; no issues spotted.


253-262: Comment may be misleading about mint destination

“NFTs will be minted to this contract first” might be false if collection mints to destinationWallet. Update comment or logic accordingly.

Apply:

-                address(this), // NFTs will be minted to this contract first
+                address(this), // purchase agent identity; mint destination is decided by the collection

177-192: Permission model vs docs: anyone with AUTHORIZED_CALLER_ROLE can move user NFTs

Docs say “msg.sender must be the from address,” but code doesn’t enforce it. Either enforce or update docs.

If enforcement is desired, I can add a new specific error and the require(msg.sender == from) check.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants