NFT Dealers

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

H-4: `collectUsdcFromSelling()` Transfers Fees Back to Contract Instead of Retaining Them

Author Revealed upon completion

Description:

In collectUsdcFromSelling(), after computing fees and amountToSeller, the function calls:

usdc.safeTransfer(address(this), fees); // ← transfers fees TO itself (no-op or waste)
usdc.safeTransfer(msg.sender, amountToSeller); // ← transfers proceeds to seller

Transferring to address(this) is effectively a no-op in most ERC-20 implementations, but the accounting line totalFeesCollected += fees is executed regardless. This means totalFeesCollected grows with each sale but the actual fee USDC is sent straight to the seller (included in amountToSeller which is calculated as listing.price - fees + collateral). The owner can then call withdrawFees() which will attempt to transfer totalFeesCollected from a balance that was never actually set aside, causing either an incorrect payment or an underflow revert.

function collectUsdcFromSelling(uint256 _listingId) external onlySeller(_listingId) {
...
uint256 fees = _calculateFees(listing.price);
uint256 amountToSeller = listing.price - fees;
uint256 collateralToReturn = collateralForMinting[listing.tokenId];
totalFeesCollected += fees;
amountToSeller += collateralToReturn;
usdc.safeTransfer(address(this), fees); // ← BUG: fee goes to self, not reserved
usdc.safeTransfer(msg.sender, amountToSeller);
}

Impact: Fee accounting is broken. The owner will be unable to withdraw correct fee amounts, and sellers may receive more USDC than intended.

Recommended Mitigation: Remove the erroneous self-transfer. The fees are already held in the contract from the buy() call:

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 collateralToReturn = collateralForMinting[listing.tokenId];
uint256 amountToSeller = listing.price - fees + collateralToReturn;
totalFeesCollected += fees;
collateralForMinting[listing.tokenId] = 0;
// Do NOT transfer fees to address(this) — they are already in the contract
usdc.safeTransfer(msg.sender, amountToSeller);
}

Proof of Concept (Forge):

function test_collectUsdc_feeAccountingBroken() public {
// Uses existing test setup from NFTDealersTest
// After buy() and collectUsdcFromSelling(), totalFeesCollected > 0
// but the contract may not hold enough USDC to cover withdrawFees()
uint256 tokenId = 1;
uint32 nftPrice = 1000e6;
// Arrange: mint, list, buy
vm.prank(owner); nftDealers.revealCollection();
vm.prank(owner); nftDealers.whitelistWallet(userWithCash);
usdc.mint(userWithCash, 20e6);
usdc.mint(userWithEvenMoreCash, 200_000e6);
vm.startPrank(userWithCash);
usdc.approve(address(nftDealers), 20e6);
nftDealers.mintNft();
nftDealers.list(tokenId, nftPrice);
vm.stopPrank();
vm.startPrank(userWithEvenMoreCash);
usdc.approve(address(nftDealers), nftPrice);
nftDealers.buy(1);
vm.stopPrank();
uint256 contractBalanceBeforeCollect = usdc.balanceOf(address(nftDealers));
vm.prank(userWithCash);
nftDealers.collectUsdcFromSelling(tokenId);
uint256 fees = nftDealers.totalFeesCollected();
uint256 contractBalanceAfterCollect = usdc.balanceOf(address(nftDealers));
// If fees were properly reserved, contract balance should equal totalFeesCollected
// This assertion will FAIL, proving the accounting is broken
assertEq(contractBalanceAfterCollect, fees,
"Contract balance does not match totalFeesCollected — fee accounting is broken");
}

Support

FAQs

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

Give us feedback!