NFTDealers::cancelListing incorrectly returns collateral allowing sellers to get free NFTs
Description
-
When a seller mints an NFT, lockAmount USDC is locked as collateral in the contract. This collateral is intended to be returned only when the NFT is sold via collectUsdcFromSelling. Cancelling a listing should only delist the NFT while keeping the collateral locked.
-
cancelListing returns the full collateral to the seller and then wipes collateralForMinting[tokenId] = 0. This creates two compounding issues: the seller receives their collateral back while still holding the NFT — effectively minting for free
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--;
usdc.safeTransfer(listing.seller, collateralForMinting[listing.tokenId]);
collateralForMinting[listing.tokenId] = 0;
emit NFT_Dealers_ListingCanceled(_listingId);
}
Risk
Likelihood: High
Impact: High
Proof of Concept
function test_CancelListingCollateralBug() public revealed whitelisted() {
uint256 tokenId = 1;
uint256 nftPrice = 1000e6;
mintAndListNFTForTesting(tokenId, nftPrice);
console.log("collateral before cancel:", nftDealers.collateralForMinting(tokenId));
console.log("userWithCash USDC before:", usdc.balanceOf(userWithCash));
vm.prank(userWithCash);
nftDealers.cancelListing(tokenId);
console.log("collateral after cancel :", nftDealers.collateralForMinting(tokenId));
console.log("userWithCash USDC after :", usdc.balanceOf(userWithCash));
console.log("userWithCash owns NFT :", nftDealers.ownerOf(tokenId) == userWithCash);
assertEq(usdc.balanceOf(userWithCash), nftDealers.lockAmount());
assertEq(nftDealers.ownerOf(tokenId), userWithCash);
assertEq(nftDealers.collateralForMinting(tokenId), 0);
}
Output
Logs:
collateral before cancel: 20000000
userWithCash USDC before: 0
collateral after cancel : 0
userWithCash USDC after : 20000000
userWithCash owns NFT : true
Recommended Mitigation
cancelListing should only delist the NFT — collateral must remain
locked until the NFT is sold or burned.
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--;
- usdc.safeTransfer(listing.seller, collateralForMinting[listing.tokenId]);
- collateralForMinting[listing.tokenId] = 0;
emit NFT_Dealers_ListingCanceled(_listingId);
}
If collateral return on cancel is intended, a separate burnNft
function should be introduced that burns the NFT and returns collateral
atomically — ensuring the seller can never hold both the NFT and the
collateral simultaneously.