The s_listings mapping (L50) is keyed by tokenId, meaning only one listing record can exist per token at any time. After buy() (L141-155) sets s_listings[_listingId].isActive = false at L152 but leaves the seller field intact, the new owner (buyer) can call list() (L127-139) which overwrites the entire s_listings[tokenId] struct at L136-137 -- replacing the original seller address with the buyer's address. The original seller can then never call collectUsdcFromSelling() (L171-183) because the onlySeller modifier (L72-75) checks s_listings[_listingId].seller == msg.sender, which now points to the buyer.
The root cause is architectural: buy() defers seller payment to a separate collectUsdcFromSelling() call, but the listing data that collectUsdcFromSelling depends on is stored in a mapping keyed by tokenId -- a key that gets reused when the token is re-listed. The listingsCounter (L46) is incremented at L133 but is never used as a mapping key, so each new listing for the same token silently destroys the previous listing's data.
This is not merely a front-running concern. Re-listing a purchased NFT is normal marketplace behavior -- a buyer who wants to flip the NFT will naturally call list() after buying, unknowingly and irreversibly blocking the original seller from collecting.
The vulnerable flow in list():
And buy() which only sets isActive = false without finalizing payment:
Compare with cancelListing() (L157-169), which pays the seller immediately and zeros collateralForMinting at L165-166, leaving no deferred state that could be overwritten.
Likelihood:
Re-listing a purchased NFT is standard marketplace behavior -- the overwrite occurs as part of a normal user flow, not just via a deliberate attack
The buyer only needs to be whitelisted (list() requires onlyWhitelisted at L127), which is a primary actor type in the protocol
The window between buy() and collectUsdcFromSelling() is unbounded -- the seller may not collect for hours or days, during which the buyer can re-list at any time
A malicious buyer can also front-run the seller's collectUsdcFromSelling transaction to guarantee the overwrite
Impact:
Original seller permanently loses sale proceeds (listing.price - fees) and minting collateral (collateralForMinting[tokenId]) -- both remain locked in the contract with no recovery mechanism
For a 100 USDC sale with 20 USDC collateral, the seller loses 119 USDC (proceeds + collateral minus fees)
The collateralForMinting[tokenId] (L51) is never zeroed by either buy() or the overwriting list(), so it persists as a ghost entry with no claimant
Every secondary sale in the marketplace is vulnerable -- this is not an edge case but a flaw in the core trading flow
Attack steps:
Seller mints an NFT via mintNft() (locks 20 USDC collateral) and lists it for 100 USDC via list()
Buyer purchases the NFT via buy(), which sets isActive = false at L152
Buyer re-lists the same tokenId via list(), which overwrites s_listings[tokenId] at L136-137 with seller = buyer
Original seller calls collectUsdcFromSelling(tokenId) -- reverts at onlySeller (L72-75) because s_listings[tokenId].seller is now the buyer
Seller's 119 USDC (sale proceeds + collateral) is permanently locked in the contract
The following Foundry test demonstrates the full attack. Run with forge test --match-test test_relistOverwriteBlocksSellerCollection -vv:
Finalize seller payment atomically inside buy(), eliminating the deferred collection step and the window for overwrite. This mirrors how cancelListing() (L157-169) already handles collateral returns immediately.
This fix:
Pays the seller (proceeds + collateral) in the same transaction as the sale -- no window for overwrite
Deletes the listing via delete s_listings[_listingId], so subsequent list() calls create a clean entry
Zeros collateralForMinting[listing.tokenId], preventing ghost collateral entries
Also eliminates the need for a separate collectUsdcFromSelling() call for the buy flow, which additionally prevents the repeated-collection vulnerability (H-01) for sold listings
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.