Description
-
After a successful sale via buy(), the original seller is expected to call collectUsdcFromSelling to receive their price - fees + collateral. The function uses onlySeller(_listingId) which checks s_listings[_listingId].seller == msg.sender.
-
Listings are keyed by tokenId (s_listings[_tokenId]). When the buyer becomes the new owner and calls list() for the same tokenId, the new listing overwrites s_listings[tokenId] entirely — including the seller field. The original seller's address is now replaced with the buyer's address, permanently failing the onlySeller check and locking the original seller's sale proceeds and collateral in the contract forever. The only guard in list() is isActive == false, which is already satisfied after a sale.
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++;
@> s_listings[_tokenId] =
Listing({seller: msg.sender, price: _price, nft: address(this), tokenId: _tokenId, isActive: true});
emit NFT_Dealers_Listed(msg.sender, listingsCounter);
}
modifier onlySeller(uint256 _listingId) {
@> require(s_listings[_listingId].seller == msg.sender, "Only seller can call this function");
_;
}
Risk
Likelihood:
-
The buyer calls list() immediately after purchase — a natural marketplace action for reselling an NFT.
-
No special conditions required: the buyer just needs to be whitelisted (which they are, since they had to be whitelisted to interact with the contract).
Impact:
Proof of Concept
Paste this function inside NFTDealersTest in test/NFTDealersTest.t.sol and run:
forge test --match-test testPoC_RelistOverwritesOriginalSellerProceeds -vvvv
function testPoC_RelistOverwritesOriginalSellerProceeds() public {
address alice = makeAddr("alice");
address bob = makeAddr("bob");
usdc.mint(alice, 20e6);
usdc.mint(bob, 100e6);
vm.prank(owner);
nftDealers.revealCollection();
vm.startPrank(owner);
nftDealers.whitelistWallet(alice);
nftDealers.whitelistWallet(bob);
vm.stopPrank();
vm.startPrank(alice);
usdc.approve(address(nftDealers), 20e6);
nftDealers.mintNft();
nftDealers.list(1, uint32(100e6));
vm.stopPrank();
vm.startPrank(bob);
usdc.approve(address(nftDealers), 100e6);
nftDealers.buy(1);
vm.stopPrank();
vm.prank(bob);
nftDealers.list(1, uint32(50e6));
vm.prank(alice);
vm.expectRevert("Only seller can call this function");
nftDealers.collectUsdcFromSelling(1);
assertEq(usdc.balanceOf(alice), 0);
assertGt(usdc.balanceOf(address(nftDealers)), 0);
}
Recommended Mitigation
- function list(uint256 _tokenId, uint32 _price) external onlyWhitelisted {
+ function list(uint256 _tokenId, uint256 _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(s_listings[_tokenId].isActive == false && s_listings[_tokenId].price == 0, "NFT has pending claim or is 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);
}