NFT Dealers

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

list() does not escrow NFT, enabling permanent griefing and stale active listings

Author Revealed upon completion

Root + Impact

Description

list() verifies ownership via ownerOf(_tokenId) == msg.sender but never transfers the NFT into the contract's custody. The seller retains full control of the NFT after listing.

  • If the seller transfers the NFT to another address, buy() reverts on _safeTransfer because the contract cannot move a token the seller no longer holds. The listing remains permanently active with no cleanup path.

function list(uint256 _tokenId, uint32 _price) external onlyWhitelisted {
require(ownerOf(_tokenId) == msg.sender, "Not owner of NFT");
// @> BUG: never calls transferFrom(msg.sender, address(this), _tokenId)
s_listings[_tokenId] = Listing({seller: msg.sender, price: _price, ...});
}
function buy(uint256 _listingId) external payable {
bool success = usdc.transferFrom(msg.sender, address(this), listing.price);
require(success, "USDC transfer failed");
_safeTransfer(listing.seller, msg.sender, listing.tokenId, "");
// @> reverts if seller no longer holds the token — listing stuck forever
}

Risk

Likelihood:

  • This occurs whenever a seller lists an NFT and then transfers it to another wallet — a common operation (gift, move to cold storage, sell on another platform)

  • A malicious seller can intentionally create permanent ghost listings

Impact:

  • Stale listings remain permanently active with no way to deactivate them (only cancelListing by seller or successful buy can set isActive = false)

  • activeListingsCounter is inflated, misleading any integration tracking marketplace activity

  • Buyers waste gas on reverting transactions when attempting to purchase ghost listings

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.34;
import {Test, console2} from "forge-std/Test.sol";
import {NFTDealers} from "../src/NFTDealers.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockUSDC is ERC20 {
constructor() ERC20("USD Coin", "USDC") {}
function mint(address to, uint256 amount) external { _mint(to, amount); }
function decimals() public pure override returns (uint8) { return 6; }
}
contract NoEscrowGriefTest is Test {
NFTDealers dealers;
MockUSDC usdc;
address owner = makeAddr("owner");
address seller = makeAddr("seller");
address buyer = makeAddr("buyer");
address alt = makeAddr("alt");
uint256 constant LOCK = 20e6;
function setUp() public {
usdc = new MockUSDC();
dealers = new NFTDealers(owner, address(usdc), "Dealers", "DEAL", "ipfs://", LOCK);
vm.startPrank(owner);
dealers.revealCollection();
dealers.whitelistWallet(seller);
vm.stopPrank();
usdc.mint(seller, 500e6);
vm.prank(seller);
usdc.approve(address(dealers), type(uint256).max);
usdc.mint(buyer, 500e6);
vm.prank(buyer);
usdc.approve(address(dealers), type(uint256).max);
}
function test_sellerTransfersNftAfterListing() public {
vm.startPrank(seller);
dealers.mintNft();
dealers.list(1, 50e6);
// Transfer NFT away after listing
dealers.transferFrom(seller, alt, 1);
vm.stopPrank();
// Listing is still active
(, , , , bool isActive) = dealers.s_listings(1);
assertTrue(isActive);
// Buyer tries to buy — reverts
vm.prank(buyer);
vm.expectRevert();
dealers.buy(1);
// Listing permanently stuck, counter inflated
assertTrue(isActive);
assertEq(dealers.totalActiveListings(), 1);
}
}

Recommended Mitigation

Transfer the NFT into escrow on listing and return it on cancel:

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");
listingsCounter++;
activeListingsCounter++;
+ transferFrom(msg.sender, address(this), _tokenId);
s_listings[_tokenId] = Listing({...});
emit NFT_Dealers_Listed(msg.sender, listingsCounter);
}

Support

FAQs

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

Give us feedback!