The list() function (L127-139) records listing metadata in s_listings[_tokenId] but does not escrow the NFT -- the seller retains full ERC721 transfer capability. If the seller transfers the listed NFT to another address via the inherited transferFrom() or safeTransferFrom(), the listing remains active in storage with a stale seller field that no longer matches the token owner.
This creates two downstream failures:
buy() permanently reverts: At L150, buy() calls _safeTransfer(listing.seller, msg.sender, listing.tokenId, ""). Since listing.seller no longer owns the token, OpenZeppelin's _transfer() reverts with ERC721IncorrectOwner. The listing appears buyable (isActive == true at L130) but every purchase attempt fails. Buyers' USDC is safe because the revert rolls back the usdc.transferFrom at L148 in the same transaction.
New owner cannot re-list: The list() function checks s_listings[_tokenId].isActive == false at L130. Since the stale listing remains active, the new NFT owner's call to list() reverts with "NFT is already listed". Only the original seller can call cancelListing() (L157-169) because of the listing.seller == msg.sender check at L160 -- the new owner has no way to clear the stale listing.
The root cause is that list() does not escrow the NFT and the contract does not override ERC721's _update() hook to invalidate listings on ownership changes. The NFTDealers contract inherits from OpenZeppelin's ERC721 (L9) which provides unrestricted transferFrom and safeTransferFrom.
Likelihood:
The seller retains full ERC721 transfer capability after listing -- standard transferFrom() and safeTransferFrom() are inherited from OpenZeppelin's ERC721 (L9) with no override
Transfers can occur intentionally (gift, OTC sale) or accidentally (seller forgets about active listing)
The stale listing persists until the original seller cooperates by calling cancelListing() (L157-169)
Impact:
No direct fund loss -- buyers' USDC is protected by the transaction reverting atomically at L150
Stale listings appear active but always revert on buy(), wasting buyer gas and degrading marketplace UX
New NFT owners are blocked from listing on the marketplace until the original seller cancels (dependency on third-party cooperation)
activeListingsCounter (L48) becomes inflated, causing totalActiveListings() (L217-219) to return misleading values
The original seller's collateral (collateralForMinting[tokenId]) remains locked until they cancel
The seller lists an NFT, then transfers it via ERC721. The buyer's buy() call reverts, and the new owner cannot re-list. Run with forge test --match-test test_staleListingAfterTransfer -vv:
Override ERC721's _update() hook to automatically invalidate active listings when token ownership changes. This is minimally invasive and keeps the non-escrow design intact.
This fix:
Automatically deactivates listings when the NFT is transferred via any ERC721 method (transferFrom, safeTransferFrom, or internal _safeTransfer in buy())
Keeps activeListingsCounter accurate
Frees the new owner to re-list without depending on the original seller
Preserves the non-escrow design -- no changes to list() or buy()
Note: cancelListing() returns collateral to listing.seller (L165), so if the transfer hook deactivates the listing, the original seller would need to claim collateral separately -- consider adding a claimCollateral() function or triggering the return in the hook
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.