buy(), the seller calls collectUsdcFromSelling() once to receive their sale proceeds (price - fees) plus the original minting collateral. The function should be a one-time claim — once the seller collects, no further withdrawal is possible for that listing.The issue: collectUsdcFromSelling() reads listing.price, listing.seller, and collateralForMinting[tokenId] to compute the payout, but never resets any of these values after transferring USDC. The function's only guard is require(!listing.isActive), which remains false indefinitely after buy() sets it. The onlySeller modifier also continues to pass because listing.seller is never cleared. This allows the seller to call the function an unlimited number of times, each time receiving the full (price - fees + collateral) payout. The funds come from USDC deposited by other users — their minting collateral and purchase payments — effectively draining the entire contract.
A secondary consequence is that totalFeesCollected += fees executes on every repeated call, inflating the fee counter far beyond the contract's actual USDC balance. This causes withdrawFees() to permanently revert, locking the owner out of fee collection.
Likelihood: High
Any whitelisted user who has completed a single NFT sale can exploit this immediately — no special privileges, no flash loans, no external dependencies, and no timing constraints are required
The function has zero guards against repeated invocation — the require(!listing.isActive) check and onlySeller modifier both pass identically on the 1st call and the 100th call, because no state is ever modified
Impact: High
Complete drainage of all USDC held in the contract, including every other user's minting collateral and pending sale proceeds — total loss of protocol funds
totalFeesCollected inflates beyond the contract's actual balance with each repeated call, causing withdrawFees() to permanently revert with an insufficient balance error — the owner loses access to all accumulated fees
The following Foundry test demonstrates the full attack path. The setup creates a realistic scenario where the contract holds funds from multiple users, then shows how a single seller drains everything.
Execution flow summary:
Victim deposits 100 USDC (5 NFT mints × 20 USDC collateral)
Attacker mints (20 USDC) and lists at 100 USDC → victim buys → contract holds 220 USDC
Attacker legitimately collects 119 USDC (sale proceeds + collateral) → contract: 101 USDC
Attacker repeats collectUsdcFromSelling(6) — no state was reset, so the exact same payout executes again, stealing from victim's locked collateral
Each additional call drains another 119 USDC until the contract is empty
The core fix is to reset all payout-related state before transferring funds, following the Checks-Effects-Interactions (CEI) pattern. This ensures the function can only pay out once per completed sale.
Why this works:
require(listing.price > 0) acts as a "collected" guard — after the first call zeroes price, all subsequent calls revert immediately
seller = address(0) breaks the onlySeller modifier for any future call attempts
collateralForMinting[tokenId] = 0 prevents double-counting of collateral
All state resets happen before the safeTransfer external call, conforming to the CEI pattern and preventing reentrancy exploitation
Removing the self-transfer (address(this)) eliminates the no-op gas waste and prevents totalFeesCollected from inflating incorrectly on repeated calls
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.