NFT Dealers

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

Collateral is reused across multiple resales, causing insolvency and stuck funds

Author Revealed upon completion

Root + Impact

  • Normal behavior: Minting locks USDC collateral per token, which should be returned only once to the original minter/seller, while the protocol keeps resale fees.

  • Bug: collectUsdcFromSelling reads collateralForMinting[tokenId] every time it’s called but never resets it, so each resale pays out the same “mint collateral” again, while totalFeesCollected is increased and a fake self-transfer is performed.

// collateral set once on mint
function mintNft() external payable onlyWhenRevealed onlyWhitelisted {
// ...
collateralForMinting[tokenIdCounter] = lockAmount;
_safeMint(msg.sender, tokenIdCounter);
}
// correctly cleared only on cancel
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;
}
// BUG: collateral reused and never cleared on sale; fees self-transferred
function collectUsdcFromSelling(uint256 _listingId) external onlySeller(_listingId) {
Listing memory listing = s_listings[_listingId];
require(!listing.isActive, "Listing must be inactive to collect USDC");
uint256 fees = _calculateFees(listing.price);
uint256 amountToSeller = listing.price - fees;
uint256 collateralToReturn = collateralForMinting[listing.tokenId];
totalFeesCollected += fees;
amountToSeller += collateralToReturn;
usdc.safeTransfer(address(this), fees); // no-op self-transfer
usdc.safeTransfer(msg.sender, amountToSeller);
}

Risk

Likelihood:

  • In any active marketplace, NFTs will be resold multiple times; after each resale, the new seller will naturally call collectUsdcFromSelling to receive proceeds.

  • Every resale after the first repeats the collateral payout (collateralForMinting[tokenId]), so this bug is hit routinely under normal usage (not just edge cases).

Impact:

  • The same mint collateral is paid out on every resale, so sellers collectively withdraw more “collateral” than was ever deposited, draining protocol reserves.

  • totalFeesCollected grows, but the fake safeTransfer(address(this), fees) does not move tokens, causing accounting to diverge from actual balance; eventually withdrawFees and/or further collectUsdcFromSelling calls can revert due to insufficient USDC, freezing protocol operations.

PoC

function testCollateralReusedAcrossResales() public {
MockUSDC usdc = new MockUSDC();
address owner = address(0xABCD);
NFTDealers dealers = new NFTDealers(owner, address(usdc), "NFTDealers", "NFTD", "img", 20e6);
address alice = address(0xA11CE);
address bob = address(0xB0B);
address carol = address(0xCAro1);
usdc.mint(alice, 20e6);
usdc.mint(bob, 1_000e6);
usdc.mint(carol, 1_000e6);
vm.prank(owner);
dealers.revealCollection();
vm.prank(owner);
dealers.whitelistWallet(alice);
// Alice mints tokenId 1 – collateralForMinting[1] = 20e6
vm.startPrank(alice);
usdc.approve(address(dealers), 20e6);
dealers.mintNft();
vm.stopPrank();
// First sale: Alice -> Bob
uint256 P = 1_000e6;
vm.startPrank(alice);
dealers.list(1, uint32(P));
vm.stopPrank();
vm.startPrank(bob);
usdc.approve(address(dealers), P);
dealers.buy(1);
vm.stopPrank();
// Alice collects: gets P - fee(P) + 20e6
vm.prank(alice);
dealers.collectUsdcFromSelling(1);
// Second sale: Bob -> Carol for same price
vm.startPrank(bob);
dealers.list(1, uint32(P));
vm.stopPrank();
vm.startPrank(carol);
usdc.approve(address(dealers), P);
dealers.buy(1);
vm.stopPrank();
// Bob collects: again receives P - fee(P) + 20e6, even though that 20e6 was already paid to Alice
vm.prank(bob);
dealers.collectUsdcFromSelling(1);
// At this point, total "collateral" paid out = 40e6 from a single 20e6 mint deposit.
}

What this PoC shows:

  • One 20 USDC collateral deposit can be paid out twice (to Alice and Bob) by repeatedly calling collectUsdcFromSelling on resales.

  • The contract’s logical accounting (“collateral + fees”) exceeds the actual USDC balance held, creating insolvency and setting up later withdrawals to fail.

Recommended Mitigation

require(!listing.isActive, "Listing must be inactive to collect USDC");
uint256 fees = _calculateFees(listing.price);
- uint256 amountToSeller = listing.price - fees;
uint256 collateralToReturn = collateralForMinting[listing.tokenId];
+ // Clear collateral so it cannot be withdrawn multiple times across resales
+ collateralForMinting[listing.tokenId] = 0;
+ uint256 amountToSeller = listing.price - fees + collateralToReturn;
totalFeesCollected += fees;
- amountToSeller += collateralToReturn;
- usdc.safeTransfer(address(this), fees);
+ // Remove self-transfer; contract already holds the funds
usdc.safeTransfer(msg.sender, amountToSeller);
}

Mitigation explanation:

  • Clearing collateralForMinting[tokenId] after the first successful collection guarantees the same collateral cannot be reused on subsequent resales.

  • Removing the self-transfer keeps totalFeesCollected aligned with the real USDC balance and avoids fake internal accounting.

Support

FAQs

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

Give us feedback!