Description
-
Under a normal marketplace design, creating a listing should either escrow the NFT into the marketplace contract or otherwise enforce that the listed asset cannot be transferred away while the listing remains active. That way, when a buyer calls buy(), the marketplace can reliably deliver the NFT associated with the active listing.
-
The issue is that list() only records listing metadata in s_listings[_tokenId], but it does not escrow the NFT and does not add any transfer restriction while the listing is active. Because the seller continues to own the token directly through the underlying ERC721 implementation, they can transfer it away after listing. Once that happens, the listing still appears active on-chain, but buy() later reverts because it tries to _safeTransfer() the token from the original seller address stored in the listing rather than from the token’s current owner.
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});
emit NFT_Dealers_Listed(msg.sender, listingsCounter);
}
function buy(uint256 _listingId) external payable {
Listing memory listing = s_listings[_listingId];
if (!listing.isActive) revert ListingNotActive(_listingId);
require(listing.seller != msg.sender, "Seller cannot buy their own NFT");
activeListingsCounter--;
bool success = usdc.transferFrom(msg.sender, address(this), listing.price);
require(success, "USDC transfer failed");
_safeTransfer(listing.seller, msg.sender, listing.tokenId, "");
s_listings[_listingId].isActive = false;
emit NFT_Dealers_Sold(msg.sender, listing.price);
}
Risk
Likelihood: High
-
This occurs whenever a seller lists an NFT and then uses the normal ERC721 transferFrom / safeTransferFrom path to move the token elsewhere, because nothing in list() prevents further transfers by the current owner.
-
This occurs during normal marketplace usage because listings are designed to remain in seller custody, so the stale-listing condition can be created without any unusual privileges beyond being the seller.
Impact: High
-
Buyers can encounter active listings that always revert at purchase time, causing denial of service, wasted gas, and broken marketplace UX.
-
The marketplace’s active listing count and on-chain listing state become unreliable because listings can remain active even though the marketplace no longer has a deliverable token associated with them.
Proof of Concept
-
Add import {console2} from "forge-std/console2.sol"; at the top of NFTDealersTest.t.sol.
-
Copy the code below to NFTDealersTest contract.
-
Run command forge test --mt testActiveListingsAreNotEscrowedAndCanBecomeStale -vv --via-ir.
function testActiveListingsAreNotEscrowedAndCanBecomeStale() public revealed whitelisted {
uint256 tokenId = 1;
uint32 listPrice = 1000e6;
address staleHolder = makeAddr("staleHolder");
mintAndListNFTForTesting(tokenId, listPrice);
(
address sellerBeforeTransfer,
uint32 storedPriceBeforeTransfer,
address nftBeforeTransfer,
uint256 storedTokenIdBeforeTransfer,
bool isActiveBeforeTransfer
) = nftDealers.s_listings(tokenId);
console2.log("Owner before external transfer:", nftDealers.ownerOf(tokenId));
console2.log("Stored seller before external transfer:", sellerBeforeTransfer);
console2.log("Stored price before external transfer:", uint256(storedPriceBeforeTransfer));
console2.log("Stored nft before external transfer:", nftBeforeTransfer);
console2.log("Stored tokenId before external transfer:", storedTokenIdBeforeTransfer);
console2.log("Stored active flag before external transfer:", isActiveBeforeTransfer ? uint256(1) : uint256(0));
console2.log("totalActiveListings before external transfer:", nftDealers.totalActiveListings());
assertEq(nftDealers.ownerOf(tokenId), userWithCash, "sanity: seller owns token before external transfer");
assertTrue(isActiveBeforeTransfer, "sanity: listing should be active before external transfer");
assertEq(nftDealers.totalActiveListings(), 1, "sanity: one active listing before external transfer");
vm.prank(userWithCash);
nftDealers.transferFrom(userWithCash, staleHolder, tokenId);
(
address sellerAfterTransfer,
uint32 storedPriceAfterTransfer,
address nftAfterTransfer,
uint256 storedTokenIdAfterTransfer,
bool isActiveAfterTransfer
) = nftDealers.s_listings(tokenId);
console2.log("Owner after external transfer:", nftDealers.ownerOf(tokenId));
console2.log("Stored seller after external transfer:", sellerAfterTransfer);
console2.log("Stored price after external transfer:", uint256(storedPriceAfterTransfer));
console2.log("Stored nft after external transfer:", nftAfterTransfer);
console2.log("Stored tokenId after external transfer:", storedTokenIdAfterTransfer);
console2.log("Stored active flag after external transfer:", isActiveAfterTransfer ? uint256(1) : uint256(0));
console2.log("totalActiveListings after external transfer:", nftDealers.totalActiveListings());
assertEq(nftDealers.ownerOf(tokenId), staleHolder, "token ownership changed outside the marketplace");
assertEq(sellerAfterTransfer, userWithCash, "listing still points to original seller");
assertTrue(isActiveAfterTransfer, "BUG: listing remains active after token left seller custody");
assertEq(nftDealers.totalActiveListings(), 1, "BUG: active listing count still includes stale listing");
vm.startPrank(userWithEvenMoreCash);
usdc.approve(address(nftDealers), listPrice);
vm.expectRevert();
nftDealers.buy(tokenId);
vm.stopPrank();
(
address sellerAfterFailedBuy,
uint32 storedPriceAfterFailedBuy,
address nftAfterFailedBuy,
uint256 storedTokenIdAfterFailedBuy,
bool isActiveAfterFailedBuy
) = nftDealers.s_listings(tokenId);
console2.log("Owner after failed buy:", nftDealers.ownerOf(tokenId));
console2.log("Stored seller after failed buy:", sellerAfterFailedBuy);
console2.log("Stored price after failed buy:", uint256(storedPriceAfterFailedBuy));
console2.log("Stored nft after failed buy:", nftAfterFailedBuy);
console2.log("Stored tokenId after failed buy:", storedTokenIdAfterFailedBuy);
console2.log("Stored active flag after failed buy:", isActiveAfterFailedBuy ? uint256(1) : uint256(0));
console2.log("totalActiveListings after failed buy:", nftDealers.totalActiveListings());
assertEq(nftDealers.ownerOf(tokenId), staleHolder, "stale holder still owns the token after failed buy");
assertTrue(isActiveAfterFailedBuy, "BUG: failed buy does not clean up stale active listing");
assertEq(nftDealers.totalActiveListings(), 1, "BUG: stale listing still counted as active");
}
Output:
[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for test/NFTDealersTest.t.sol:NFTDealersTest
[PASS] testActiveListingsAreNotEscrowedAndCanBecomeStale() (gas: 513003)
Logs:
Owner before external transfer: 0x22CdC71E987473D657FCe79C9C0C0B1A62148056
Stored seller before external transfer: 0x22CdC71E987473D657FCe79C9C0C0B1A62148056
Stored price before external transfer: 1000000000
Stored nft before external transfer: 0x2e234DAe75C793f67A35089C9d99245E1C58470b
Stored tokenId before external transfer: 1
Stored active flag before external transfer: 1
totalActiveListings before external transfer: 1
Owner after external transfer: 0x928f16eb8A5F541De90d9aaF9893cEff4F8C0E8e
Stored seller after external transfer: 0x22CdC71E987473D657FCe79C9C0C0B1A62148056
Stored price after external transfer: 1000000000
Stored nft after external transfer: 0x2e234DAe75C793f67A35089C9d99245E1C58470b
Stored tokenId after external transfer: 1
Stored active flag after external transfer: 1
totalActiveListings after external transfer: 1
Owner after failed buy: 0x928f16eb8A5F541De90d9aaF9893cEff4F8C0E8e
Stored seller after failed buy: 0x22CdC71E987473D657FCe79C9C0C0B1A62148056
Stored price after failed buy: 1000000000
Stored nft after failed buy: 0x2e234DAe75C793f67A35089C9d99245E1C58470b
Stored tokenId after failed buy: 1
Stored active flag after failed buy: 1
totalActiveListings after failed buy: 1
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.72ms (1.12ms CPU time)
Ran 1 test suite in 11.94ms (3.72ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Recommended Mitigation
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");
+ // Escrow the NFT into the marketplace contract
+ _safeTransfer(msg.sender, address(this), _tokenId, "");
listingsCounter++;
activeListingsCounter++;
s_listings[_tokenId] =
Listing({seller: msg.sender, price: _price, nft: address(this), tokenId: _tokenId, isActive: true});
emit NFT_Dealers_Listed(msg.sender, listingsCounter);
}
function buy(uint256 _listingId) external payable {
Listing memory listing = s_listings[_listingId];
if (!listing.isActive) revert ListingNotActive(_listingId);
require(listing.seller != msg.sender, "Seller cannot buy their own NFT");
activeListingsCounter--;
bool success = usdc.transferFrom(msg.sender, address(this), listing.price);
require(success, "USDC transfer failed");
- _safeTransfer(listing.seller, msg.sender, listing.tokenId, "");
+ _safeTransfer(address(this), msg.sender, listing.tokenId, "");
s_listings[_listingId].isActive = false;
emit NFT_Dealers_Sold(msg.sender, listing.price);
}
function cancelListing(uint256 _listingId) external {
Listing memory listing = s_listings[_listingId];
if (!listing.isActive) revert ListingNotActive(_listingId);
require(listing.seller == msg.sender, "Only seller can cancel listing");
s_listings[_listingId].isActive = false;
activeListingsCounter--;
+ // Return the NFT from escrow to the seller
+ _safeTransfer(address(this), listing.seller, listing.tokenId, "");
+
usdc.safeTransfer(listing.seller, collateralForMinting[listing.tokenId]);
collateralForMinting[listing.tokenId] = 0;
emit NFT_Dealers_ListingCanceled(_listingId);
}