NFT Dealers

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

Fees only accounted on seller claim

Author Revealed upon completion

Description

  • Under the intended settlement flow, the protocol fee for a sale should become owner-withdrawable as soon as the sale is completed, because the buyer’s full USDC payment already enters the contract during buy(). The seller’s later claim should only release the seller’s portion of that already-received payment (plus any collateral return), while the fee portion should remain recognized as protocol revenue.

  • The issue is that the contract does not account protocol fees at sale time. Instead, it only increments totalFeesCollected inside collectUsdcFromSelling(), which is a seller-triggered action executed later. As a result, after a successful buy(), the fee portion already sits inside the contract balance, but the owner still cannot withdraw it because withdrawFees() only looks at totalFeesCollected. When the seller never calls collectUsdcFromSelling(), the fee remains trapped inside the pooled contract balance indefinitely and is never recognized as withdrawable protocol profit.

function buy(uint256 _listingId) external payable {
Listing memory listing = s_listings[_listingId];
if (!listing.isActive) revert ListingNotActive(_listingId);
require(listing.seller != msg.sender, "Seller cannot buy their own NFT");
activeListingsCounter--;
bool success = usdc.transferFrom(msg.sender, address(this), listing.price); // @> full buyer payment enters contract here
require(success, "USDC transfer failed");
_safeTransfer(listing.seller, msg.sender, listing.tokenId, "");
s_listings[_listingId].isActive = false;
emit NFT_Dealers_Sold(msg.sender, listing.price);
}
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; // @> fee accounting happens only when seller later claims
amountToSeller += collateralToReturn;
usdc.safeTransfer(address(this), fees);
usdc.safeTransfer(msg.sender, amountToSeller);
}
function withdrawFees() external onlyOwner {
require(totalFeesCollected > 0, "No fees to withdraw"); // @> owner depends entirely on the later seller claim
uint256 amount = totalFeesCollected;
totalFeesCollected = 0;
usdc.safeTransfer(owner, amount);
emit NFT_Dealers_F

Risk

Likelihood: High

  • This occurs whenever a sale completes and the seller delays or never performs collectUsdcFromSelling(), because buy() itself does not record any protocol fees into totalFeesCollected.

  • This occurs during normal marketplace usage because seller collection is intentionally split into a separate later transaction rather than happening atomically during buy().

Impact: High

  • The owner cannot withdraw fee revenue from completed sales until the seller claims, even though the fee funds are already present in the contract.

  • In practical scenarios where the seller loses access, disappears, or simply never claims, the fee portion can remain stuck indefinitely inside the contract balance instead of being withdrawable as protocol revenue.

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 testFeesOnlyAccountedOnSellerClaimStuckFeesIfSellerNeverCollects -vv --via-ir.

function testFeesOnlyAccountedOnSellerClaimStuckFeesIfSellerNeverCollects() public revealed whitelisted {
uint256 tokenId = 1;
uint32 salePrice = 1000e6;
uint256 expectedFee = nftDealers.calculateFees(salePrice);
uint256 expectedSellerProceeds = uint256(salePrice) - expectedFee;
uint256 expectedContractBalanceAfterBuy = nftDealers.lockAmount() + uint256(salePrice);
console2.log("salePrice:", uint256(salePrice));
console2.log("expectedFee:", expectedFee);
console2.log("expectedSellerProceeds:", expectedSellerProceeds);
console2.log("lockAmount:", nftDealers.lockAmount());
// ------------------------------------------------------------
// Phase 1: seller mints and lists token #1
// ------------------------------------------------------------
mintAndListNFTForTesting(tokenId, salePrice);
console2.log("Contract USDC after mint+list:", usdc.balanceOf(address(nftDealers)));
console2.log("Seller USDC after mint+list:", usdc.balanceOf(userWithCash));
console2.log("totalFeesCollected before sale:", nftDealers.totalFeesCollected());
assertEq(
usdc.balanceOf(address(nftDealers)), nftDealers.lockAmount(), "sanity: only collateral held before sale"
);
assertEq(nftDealers.totalFeesCollected(), 0, "sanity: no fees before sale");
// ------------------------------------------------------------
// Phase 2: buyer purchases token #1
// Seller intentionally does NOT call collectUsdcFromSelling()
// ------------------------------------------------------------
vm.startPrank(userWithEvenMoreCash);
usdc.approve(address(nftDealers), salePrice);
nftDealers.buy(tokenId);
vm.stopPrank();
uint256 contractBalanceAfterBuy = usdc.balanceOf(address(nftDealers));
uint256 sellerBalanceAfterBuy = usdc.balanceOf(userWithCash);
uint256 totalFeesAfterBuy = nftDealers.totalFeesCollected();
console2.log("Contract USDC after buy:", contractBalanceAfterBuy);
console2.log("Seller USDC after buy (before collect):", sellerBalanceAfterBuy);
console2.log("totalFeesCollected after buy:", totalFeesAfterBuy);
console2.log("Owner USDC before withdraw attempt:", usdc.balanceOf(owner));
// Core bug signal:
// the fee is already inside the contract balance, but it is not yet recognized as withdrawable.
assertEq(
contractBalanceAfterBuy,
expectedContractBalanceAfterBuy,
"sanity: contract holds collateral + full buyer payment after sale"
);
assertEq(sellerBalanceAfterBuy, 0, "sanity: seller has not claimed yet, so proceeds remain in the contract");
assertEq(totalFeesAfterBuy, 0, "BUG: fee from completed sale is not accounted until seller claim");
// ------------------------------------------------------------
// Phase 3: owner cannot withdraw protocol fees from the completed sale
// ------------------------------------------------------------
vm.prank(owner);
vm.expectRevert("No fees to withdraw");
nftDealers.withdrawFees();
uint256 ownerBalanceAfterFailedWithdraw = usdc.balanceOf(owner);
uint256 contractBalanceAfterFailedWithdraw = usdc.balanceOf(address(nftDealers));
console2.log("Owner USDC after failed withdraw:", ownerBalanceAfterFailedWithdraw);
console2.log("Contract USDC after failed withdraw:", contractBalanceAfterFailedWithdraw);
// expectedFee is already part of the contract's balance after the sale,
// but the owner still cannot withdraw any protocol fee because accounting is deferred to seller claim.
assertEq(ownerBalanceAfterFailedWithdraw, 0, "BUG: owner could not withdraw realized sale fee");
assertEq(
contractBalanceAfterFailedWithdraw,
expectedContractBalanceAfterBuy,
"fee remains trapped in pooled contract funds until seller claim"
);
}

Output:

[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for test/NFTDealersTest.t.sol:NFTDealersTest
[PASS] testFeesOnlyAccountedOnSellerClaimStuckFeesIfSellerNeverCollects() (gas: 454388)
Logs:
salePrice: 1000000000
expectedFee: 10000000
expectedSellerProceeds: 990000000
lockAmount: 20000000
Contract USDC after mint+list: 20000000
Seller USDC after mint+list: 0
totalFeesCollected before sale: 0
Contract USDC after buy: 1020000000
Seller USDC after buy (before collect): 0
totalFeesCollected after buy: 0
Owner USDC before withdraw attempt: 0
Owner USDC after failed withdraw: 0
Contract USDC after failed withdraw: 1020000000
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 4.32ms (1.15ms CPU time)
Ran 1 test suite in 15.62ms (4.32ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommended Mitigation

  • The fix is to account protocol fees at sale time rather than at seller-claim time. Since the contract already receives the full buyer payment during buy(), it can compute the fee immediately and increment totalFeesCollected there.

  • Then collectUsdcFromSelling() should only pay the seller’s net proceeds plus any valid collateral return, without separately “creating” the fee accounting later.

function buy(uint256 _listingId) external payable {
Listing memory listing = s_listings[_listingId];
if (!listing.isActive) revert ListingNotActive(_listingId);
require(listing.seller != msg.sender, "Seller cannot buy their own NFT");
activeListingsCounter--;
bool success = usdc.transferFrom(msg.sender, address(this), listing.price);
require(success, "USDC transfer failed");
+ uint256 fees = _calculateFees(listing.price);
+ totalFeesCollected += fees;
+
_safeTransfer(listing.seller, msg.sender, listing.tokenId, "");
s_listings[_listingId].isActive = false;
emit NFT_Dealers_Sold(msg.sender, listing.price);
}
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 fees = _calculateFees(listing.price);
+ uint256 amountToSeller = listing.price - fees;
uint256 collateralToReturn = collateralForMinting[listing.tokenId];
- totalFeesCollected += fees;
amountToSeller += collateralToReturn;
- 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!