NFT Dealers

First Flight #58
Beginner FriendlyFoundry
100 EXP
Submission Details
Impact: low
Likelihood: low

`cancelListing()` zeros `collateralForMinting` after the external USDC transfer, violating the Checks-Effects-Interactions pattern

Author Revealed upon completion

Root + Impact

Description

In cancelListing() (L157-169), the state update collateralForMinting[listing.tokenId] = 0 at L166 occurs after the external call usdc.safeTransfer(listing.seller, collateralForMinting[listing.tokenId]) at L165. This violates the Checks-Effects-Interactions (CEI) pattern — a well-established Solidity best practice for preventing reentrancy.

Notably, the function partially follows CEI: s_listings[_listingId].isActive = false at L162 and activeListingsCounter-- at L163 are both set before the external call. But collateralForMinting[listing.tokenId] is zeroed after it:

function cancelListing(uint256 _listingId) external { // @audit L157
Listing memory listing = s_listings[_listingId]; // @audit L158
if (!listing.isActive) revert ListingNotActive(_listingId); // @audit L159
require(listing.seller == msg.sender, "Only seller can cancel listing"); // @audit L160
s_listings[_listingId].isActive = false; // @audit L162: effect BEFORE interaction (correct)
activeListingsCounter--; // @audit L163: effect BEFORE interaction (correct)
usdc.safeTransfer(listing.seller, collateralForMinting[listing.tokenId]); // @audit L165: EXTERNAL CALL (interaction)
collateralForMinting[listing.tokenId] = 0; // @audit L166: effect AFTER interaction (CEI violation)
emit NFT_Dealers_ListingCanceled(_listingId); // @audit L168
}

During the external call at L165, collateralForMinting[listing.tokenId] is still non-zero. If the USDC token had transfer hooks (e.g., ERC-777 tokensReceived), a malicious seller contract could re-enter the protocol during the callback. At that point, collectUsdcFromSelling() (L171-183) would read the still-non-zero collateralForMinting[listing.tokenId] at L177 and add it to the seller's payout at L180 — on top of the collateral already being transferred by cancelListing.

The re-entrant call path during the callback would be:

  1. cancelListing() transfers collateral at L165 (callback fires here)

  2. During callback: seller calls collectUsdcFromSelling(_listingId)

  3. onlySeller modifier (L72-75): s_listings[_listingId].seller == msg.sender — passes (seller field not cleared)

  4. !listing.isActive at L173 — passes (set to false at L162 before the external call)

  5. collateralForMinting[listing.tokenId] at L177 — still non-zero (not yet zeroed at L166)

  6. Seller receives listing.price - fees + collateral — the collateral is double-counted

With the current MockUSDC (standard ERC-20, L5 imports OpenZeppelin's SafeERC20), this is not directly exploitable because ERC-20 transfer() does not invoke callbacks on the recipient. The safeTransfer wrapper (from OpenZeppelin's SafeERC20 at L10) calls IERC20.transfer(), which has no recipient hooks.

Compare with collectUsdcFromSelling() (L171-183), which has the same patterncollateralForMinting is never zeroed at all, which is a separate accepted finding (H-01). The CEI issue in cancelListing is distinct: it does zero the mapping, but does so in the wrong order relative to the external call.

Risk

Likelihood:

  • The CEI violation is present in every cancelListing() call — collateralForMinting is zeroed at L166 after the external usdc.safeTransfer at L165

  • With standard ERC-20 USDC (no transfer hooks), the reentrancy window cannot be exploited

  • If the protocol were deployed with a token that has transfer hooks (ERC-777, or a future USDC upgrade with hooks), the window would become exploitable

Impact:

  • With standard USDC: no direct fund loss — this is a best-practice violation only

  • With a hooked token (theoretical): a malicious seller could double-claim collateral by re-entering collectUsdcFromSelling during the callback, stealing from other users' funds

Proof of Concept

The following test confirms the CEI ordering issue — collateralForMinting is non-zero during the external call window. Run with forge test --match-test test_collateralNonZeroDuringExternalCall -vv:

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.34;
import {Test, console} from "forge-std/Test.sol";
import {NFTDealers} from "../src/NFTDealers.sol";
import {MockUSDC} from "../src/MockUSDC.sol";
contract L03PoCTest is Test {
NFTDealers public nftDealers;
MockUSDC public usdc;
address public owner = makeAddr("owner");
address public seller = makeAddr("seller");
uint256 constant LOCK_AMOUNT = 20e6; // 20 USDC
function setUp() public {
usdc = new MockUSDC();
nftDealers = new NFTDealers(owner, address(usdc), "NFTDealers", "NFTD", "ipfs://test/", LOCK_AMOUNT);
vm.startPrank(owner);
nftDealers.revealCollection();
nftDealers.whitelistWallet(seller);
vm.stopPrank();
usdc.mint(seller, 1000e6);
vm.prank(seller);
usdc.approve(address(nftDealers), type(uint256).max);
}
function test_collateralNonZeroDuringExternalCall() public {
// Seller mints and lists
vm.prank(seller);
nftDealers.mintNft(); // tokenId 1
vm.prank(seller);
nftDealers.list(1, uint32(100e6));
// Collateral is set before cancel
assertEq(nftDealers.collateralForMinting(1), LOCK_AMOUNT);
// Cancel listing
vm.prank(seller);
nftDealers.cancelListing(1);
// Post-transaction: collateral IS zeroed (but zeroing happened AFTER the transfer)
assertEq(nftDealers.collateralForMinting(1), 0);
// The CEI violation: during usdc.safeTransfer at L165,
// collateralForMinting[1] was still 20e6 (non-zero).
// With a hooked token, re-entering collectUsdcFromSelling(1)
// would read this non-zero value at L177.
console.log("CEI violation: collateralForMinting zeroed AFTER external call (L166 after L165)");
console.log("isActive correctly set BEFORE external call (L162 before L165)");
}
}

Recommended Mitigation

Cache the collateral value, zero the mapping, then perform the external transfer — following the CEI pattern consistently for all state updates in the function.

function cancelListing(uint256 _listingId) external {
Listing memory listing = s_listings[_listingId];
if (!listing.isActive) revert ListingNotActive(_listingId);
require(listing.seller == msg.sender, "Only seller can cancel listing");
s_listings[_listingId].isActive = false;
activeListingsCounter--;
- usdc.safeTransfer(listing.seller, collateralForMinting[listing.tokenId]);
- collateralForMinting[listing.tokenId] = 0;
+ uint256 collateral = collateralForMinting[listing.tokenId];
+ collateralForMinting[listing.tokenId] = 0;
+ usdc.safeTransfer(listing.seller, collateral);
emit NFT_Dealers_ListingCanceled(_listingId);
}

This fix:

  1. Caches collateralForMinting[listing.tokenId] in a local variable before zeroing

  2. Zeros the mapping before the external usdc.safeTransfer call — closing the reentrancy window

  3. Passes the cached value to safeTransfer — functionally equivalent, no change in payout amount

  4. Aligns all state updates (isActive, activeListingsCounter, and now collateralForMinting) before the external call, achieving full CEI compliance

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!