NFT Dealers

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

cancelListing Returns Collateral While Seller Retains the NFT, Nullifying the Collateral Mechanism

Author Revealed upon completion

Root + Impact

Description

  • The protocol's collateral design is intended to bind an economic cost to NFT ownership: collateral is locked at mint time and only returned together with sale proceeds after a genuine purchase, incentivising real transactions.

  • cancelListing refunds collateralForMinting[tokenId] to the seller at cancellation time, even though the NFT is simultaneously returned to the seller's wallet. A user can therefore execute mint → list → immediate cancel to simultaneously hold both the NFT and the full collateral amount, completely defeating the economic constraint the collateral was designed to impose.

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]);
// @> BUG: NFT is still transferred back to the seller on the next line;
// collateral must not be returned until the NFT is actually sold
@> collateralForMinting[listing.tokenId] = 0;
_safeTransfer(address(this), listing.seller, listing.tokenId);
}

Risk

Likelihood:

  • Minting an NFT, listing it, and immediately cancelling is a fully legitimate user flow that requires no special conditions — any whitelisted user can trigger it at zero marginal cost.

  • All whitelisted users can exploit this continuously and independently after the protocol launches.

Impact:

  • The collateral mechanism becomes entirely inoperative, stripping the protocol of its primary economic tool for constraining user behaviour.

  • Users can repeatedly mint and cancel at no cost, occupying NFT supply and withdrawing the USDC the protocol intended to keep locked.

Proof of Concept

Add this to 2026-03-NFT-dealers/test/NFTDealersTest.t.sol,run forge test --match-test testPoC_C02_CollateralReturnedOnCancel -vvvv

function testPoC_C02_CollateralReturnedOnCancel() public revealed {
uint256 tokenId = 1;
uint256 lockAmt = nftDealers.lockAmount();
vm.prank(owner);
nftDealers.whitelistWallet(userWithCash);
uint256 balanceBefore = usdc.balanceOf(userWithCash);
vm.startPrank(userWithCash);
usdc.approve(address(nftDealers), lockAmt);
nftDealers.mintNft(); // locks 20 USDC
nftDealers.list(tokenId, 1000e6);
nftDealers.cancelListing(tokenId); // cancels, refunds 20 USDC
vm.stopPrank();
uint256 balanceAfter = usdc.balanceOf(userWithCash);
// Alice has her full balance back AND still holds the NFT
assertEq(balanceAfter, balanceBefore,
"C-02: collateral fully returned on cancel despite NFT retention");
assertEq(nftDealers.ownerOf(tokenId), userWithCash,
"C-02: seller still holds the NFT after collateral was refunded");
}

Recommended Mitigation

Remove the collateral refund from cancelListing entirely. Collateral should remain locked for the lifetime of NFT ownership and be returned only through collectUsdcFromSelling after a verified sale.

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;
+ // Collateral is intentionally retained; it is only returned via
+ // collectUsdcFromSelling after a confirmed sale.
_safeTransfer(address(this), listing.seller, listing.tokenId);
emit NFT_Dealers_ListingCanceled(_listingId);
}

Support

FAQs

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

Give us feedback!