NFT Dealers

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

Self-Transfer Bug in `collectUsdcFromSelling` Transfers Fees to `address(this)`

Author Revealed upon completion

Root + Impact

  • The fee transfer in collectUsdcFromSelling sends USDC back to the contract itself, making it a no-op. Meanwhile totalFeesCollected keeps accumulating, causing withdrawFees to overdraw from the contract balance and steal funds belonging to other users.

Description

  • usdc.safeTransfer(address(this), fees) transfers to the contract itself. The balance does not change

  • totalFeesCollected is still incremented by fees on every call, making the accounting inconsistent with the actual isolated fee balance

  • When the owner calls withdrawFees, it withdraws totalFeesCollected from the general contract balance, which includes USDC that belongs to other sellers as collateral or pending proceeds

totalFeesCollected += fees;
// @ audit transfers to itself — this is a no-op, fees are not isolated
usdc.safeTransfer(address(this), fees);
usdc.safeTransfer(msg.sender, amountToSeller);

Risk

Likelihood:

  • Triggered every time a seller calls collectUsdcFromSelling . This is the normal happy path

  • No special conditions required

Impact:

  • withdrawFees drains the USDC that belongs to other users' collateral or pending proceeds

  • Sellers who have not yet called collectUsdcFromSelling may find the contract balance insufficient when they do, causing their transactions to revert

  • Protocol fee accounting is entirely broken

Proof of Concept

The scenario below shows how the self-transfer causes totalFeesCollected to diverge from the actual available fee balance. When withdrawFees is called, it attempts to withdraw an amount larger than what the fees actually represent, pulling USDC from funds that belong to other participants in the protocol.

// Setup: Alice and Bob each sell an NFT for 1000 USDC (fee = 10 USDC each)
// Step 1: Alice collects proceeds
dealers.collectUsdcFromSelling(aliceListingId);
// Alice receives 990 USDC + 20 USDC collateral = 1010 USDC
// usdc.safeTransfer(address(this), 10) is a no-op — nothing is isolated
// totalFeesCollected = 10
// Step 2: Bob collects proceeds
dealers.collectUsdcFromSelling(bobListingId);
// totalFeesCollected = 20
// Step 3: Owner withdraws fees
// withdrawFees sends 20 USDC to owner
// But only 10 USDC of real fees were generated — the other 10 USDC belongs to Bob
dealers.withdrawFees(); // steals 10 USDC from remaining contract balance

Recommended Mitigation

Remove the self-transfer line entirely. The USDC is already held in the contract's balance when a buyer calls buy(), so there is nothing to move. The fees are implicitly retained by not paying them out to the seller. The totalFeesCollected counter alone is sufficient for withdrawFees to know how much to send to the owner. Removing the no-op line also saves gas on every seller payout.

totalFeesCollected += fees;
- usdc.safeTransfer(address(this), fees); // remove — no-op, fees already held in contract balance
usdc.safeTransfer(msg.sender, amountToSeller);

Support

FAQs

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

Give us feedback!