NFT Dealers

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

Re-Listing a Token Overwrites Previous Seller's Listing Data, Permanently Locking Their Uncollected Sale Proceeds

Author Revealed upon completion

Summary

When a buyer purchases an NFT and re-lists it before the original seller calls collectUsdcFromSelling, the list() function fully overwrites s_listings[tokenId] — destroying the original seller's data. Since collectUsdcFromSelling uses the onlySeller modifier which checks s_listings[tokenId].seller, the original seller is permanently blocked from collecting their sale proceeds and collateral. Their funds are locked in the contract forever with no recovery mechanism.

Impact

Any seller who does not immediately call collectUsdcFromSelling after their NFT is sold risks permanent loss of their funds. The buyer has every right to re-list the token they now own, and the protocol imposes no time limit or urgency on the seller to collect. Yet a single re-list by the buyer destroys the seller's access to money the protocol is holding on their behalf.

Consider Alice, who mints an NFT ($20 collateral) and lists it for $1,000 USDC. Bob buys it. Alice plans to collect her proceeds later that day, but Bob re-lists the token within minutes. Alice's listing entry is overwritten — seller is now Bob, not Alice. When Alice calls collectUsdcFromSelling, the onlySeller modifier reverts because the contract no longer recognizes her as the seller. Alice's $1,000 sale proceeds and $20 collateral are permanently locked in the contract. There is no admin function, no alternative path, and no way to recover the funds.

Critically, this issue cascades across every resale of the same token. Once the token is re-listed, the original seller is blocked by two independent locks: (1) onlySeller fails because the seller field now points to the new lister, and (2) require(!listing.isActive) fails because the new listing is active. If the pattern repeats — Bob sells to Carol, Carol re-lists before Bob collects — Bob's proceeds are also permanently locked. Every seller in the chain loses their funds if the next buyer re-lists before they collect. USDC accumulates in the contract with no one able to claim it. The buyer does not need to act maliciously — simply using the protocol normally (buying and re-listing) triggers this loss. There is no event, no warning, and no time limit indicating that collection is urgent.

Vulnerability Details

The root cause is that list() uses _tokenId as the mapping key and performs a full overwrite with no check for uncollected proceeds from a prior sale:

// NFTDealers.sol:131-142
function list(uint256 _tokenId, uint32 _price) external onlyWhitelisted {
require(_price >= MIN_PRICE, "Price must be at least 1 USDC");
require(ownerOf(_tokenId) == msg.sender, "Not owner of NFT");
require(s_listings[_tokenId].isActive == false, "NFT is already listed");
require(_price > 0, "Price must be greater than 0");
listingsCounter++;
activeListingsCounter++;
s_listings[_tokenId] =
Listing({seller: msg.sender, price: _price, nft: address(this), tokenId: _tokenId, isActive: true});
// ^^^ Full overwrite — previous seller's data is destroyed
emit NFT_Dealers_Listed(msg.sender, listingsCounter);
}

After buy() executes, the listing becomes inactive but the original seller's data persists:

// buy() — line 156
s_listings[_listingId].isActive = false;
// seller field is NOT cleared — original seller can still collect

When the buyer calls list() with the same token ID, all three require checks pass:

  • ownerOf(_tokenId) == msg.sender — buyer owns it now

  • s_listings[_tokenId].isActive == falsebuy() set it to false

  • Price checks pass

The full overwrite on line 140-141 replaces the seller address with the new lister. The original seller's collectUsdcFromSelling call then fails at the onlySeller modifier:

// NFTDealers.sol:76-79
modifier onlySeller(uint256 _listingId) {
require(s_listings[_listingId].seller == msg.sender, "Only seller can call this function");
_;
}

Since s_listings[tokenId].seller is now the buyer (new lister), the original seller can never pass this check.

Code Location

  • src/NFTDealers.sol:140-141s_listings[_tokenId] full overwrite with no check for uncollected proceeds

  • src/NFTDealers.sol:134isActive == false check passes after buy(), enabling re-list

  • src/NFTDealers.sol:76-79onlySeller modifier blocks original seller after overwrite

  • src/NFTDealers.sol:156buy() sets isActive = false but preserves seller data (correct, but unprotected from overwrite)

Proof of Concept

POC Guide

  1. Copy the test code below

  2. Paste it into test/NFTDealersTest.t.sol

  3. Run: forge test --mt test_relistOverwritesPendingProceeds_POC -vvv

Test Code

Location: test/NFTDealersTest.t.sol

function test_relistOverwritesPendingProceeds_POC() public revealed {
// Setup: whitelist both users
vm.startBroadcast(owner);
nftDealers.whitelistWallet(userWithCash);
nftDealers.whitelistWallet(userWithEvenMoreCash);
vm.stopBroadcast();
// Alice (userWithCash) mints token 1
vm.startBroadcast(userWithCash);
usdc.approve(address(nftDealers), 20e6);
nftDealers.mintNft(); // token 1
vm.stopBroadcast();
// Alice lists token 1 for $100
vm.prank(userWithCash);
nftDealers.list(1, uint32(100e6));
// Bob (userWithEvenMoreCash) buys token 1
vm.startBroadcast(userWithEvenMoreCash);
usdc.approve(address(nftDealers), 100e6);
nftDealers.buy(1);
vm.stopBroadcast();
// At this point: listing is inactive, Alice is seller, Alice has NOT collected yet
(address sellerBeforeRelist,,,,) = nftDealers.s_listings(1);
assertEq(sellerBeforeRelist, userWithCash, "Alice is still the seller before re-list");
emit log_string("Alice has pending proceeds to collect...");
// Bob re-lists token 1 — OVERWRITES Alice's listing data
vm.prank(userWithEvenMoreCash);
nftDealers.list(1, uint32(200e6));
// Alice's listing is now gone — seller is Bob
(address sellerAfterRelist,,,,) = nftDealers.s_listings(1);
assertEq(sellerAfterRelist, userWithEvenMoreCash, "Bob is now the seller - Alice's data overwritten");
emit log_string("Alice's listing data OVERWRITTEN by Bob's re-list");
// Alice tries to collect her sale proceeds — REVERTS
vm.prank(userWithCash);
vm.expectRevert("Only seller can call this function");
nftDealers.collectUsdcFromSelling(1);
emit log_string("CONFIRMED: Alice can never collect her $100 sale proceeds + $20 collateral");
emit log_named_uint("Alice USDC balance (lost proceeds)", usdc.balanceOf(userWithCash));
}

Output

Ran 1 test for test/NFTDealersTest.t.sol:NFTDealersTest
[PASS] test_relistOverwritesPendingProceeds_POC() (gas: 380346)
Logs:
Alice has pending proceeds to collect...
Alice's listing data OVERWRITTEN by Bob's re-list
CONFIRMED: Alice can never collect her $100 sale proceeds + $20 collateral
Alice USDC balance (lost proceeds): 0
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 9.12ms (2.01ms CPU time)

Mitigation

Add a check in list() to prevent re-listing a token that has uncollected proceeds from a prior sale. The simplest approach is to verify that collateralForMinting[_tokenId] has been zeroed (indicating the previous seller has collected or cancelled):

function list(uint256 _tokenId, uint32 _price) external onlyWhitelisted {
require(_price >= MIN_PRICE, "Price must be at least 1 USDC");
require(ownerOf(_tokenId) == msg.sender, "Not owner of NFT");
require(s_listings[_tokenId].isActive == false, "NFT is already listed");
require(_price > 0, "Price must be greater than 0");
+ require(collateralForMinting[_tokenId] == 0, "Previous seller has uncollected proceeds");

Alternatively, use listingsCounter as the mapping key instead of _tokenId, giving each listing a unique ID that is never overwritten. This would require updating all functions that reference s_listings to use listing IDs instead of token IDs.

Support

FAQs

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

Give us feedback!