After a sale is completed via buy(), the seller is expected to call collectUsdcFromSelling once to retrieve their sale proceeds and their original minting collateral.
The function checks that the listing is inactive but has no guard that prevents it from being called multiple times. collateralForMinting[listing.tokenId] is never zeroed after the first payout and no collected state is recorded. Every subsequent call re-reads the original collateral amount and re-sends the full payout to the seller, draining USDC that belongs to other minters.
Likelihood: High
Any whitelisted seller whose NFT has been sold can call this function repeatedly with no additional setup or special conditions.
The only natural limit is the contract's USDC balance; while other minters have collateral locked, each additional call drains it.
Impact: High
The seller extracts listing.price - fees + lockAmount from the contract on every additional call, consuming USDC that belongs to other minters as their locked collateral.
With enough repeated calls the entire USDC balance of the contract is drained, permanently preventing every other minter from recovering their 20 USDC deposit.
The attack requires other users to have minted NFTs so their collateral is available in the contract. The test below uses two victim minters going through the full mint flow, reflecting realistic on-chain state.
The NFT is priced at 10 USDC so that the per-collect payout (10 - 0.1 fee + 20 collateral = 29.9 USDC) stays below the victim collateral available after the first collect (40.1 USDC), confirming the second call succeeds without reverting.
Seller mints tokenId=1 in setUp, locking 20 USDC collateral.
Two victim users mint NFTs (tokenId=2, tokenId=3), locking 20 USDC each. Contract holds 60 USDC total.
Seller lists tokenId=1 at 10 USDC. Buyer purchases it. Contract holds 70 USDC (60 + 10).
First collectUsdcFromSelling is legitimate: seller receives 10 - 0.1 (fee) + 20 (collateral) = 29.9 USDC. Contract holds 40.1 USDC (0.1 fee + 40 victim collateral).
Second call succeeds: no guard fires, another 29.9 USDC is drained from victim collateral.
Add a dedicated s_collected mapping. Zero it and collateralForMinting before any transfer (CEI). Remove the erroneous self-transfer.
Note: the usdc.safeTransfer(address(this), fees) self-transfer is removed here as it is a no-op
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.