NFT Dealers

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

collectUsdcFromSelling Self-Transfers Fees to the Contract Itself, Permanently Bricking withdrawFees()

Author Revealed upon completion

Root + Impact

Description

NFTDealers.sol:56, NFTDealers.sol:127, NFTDealers.sol:185

  • The Listing struct declares price as uint32, and both list() and updatePrice() accept a uint32 price parameter. The fee schedule, however, compares prices against uint256 thresholds — MID_FEE_THRESHOLD = 10_000e6 — which exceeds uint32 max by a factor of ~2.3×. Because every valid uint32 value is below MID_FEE_THRESHOLD, the HIGH (5%) fee branch can never evaluate to true.

  • The existing test suite provides false assurance: testCalculateFeesHigh passes by calling the public calculateFees(uint256) function directly at 15,500 USDC — a price 3.6× above uint32_max that can never be stored in Listing.price. The HIGH tier is verified in isolation but permanently inert under normal protocol usage.

// @> NFTDealers.sol:54-60
struct Listing {
address seller;
uint32 price; // @audit max = 4,294,967,295 raw ≈ 4,294 USDC (6 dp)
address nft;
uint256 tokenId;
bool isActive;
}
// @> NFTDealers.sol:127
function list(uint256 _tokenId, uint32 _price) external onlyWhitelisted { ... }
// @> NFTDealers.sol:185
function updatePrice(uint256 _listingId, uint32 _newPrice) external onlySeller(_listingId) { ... }
// @> NFTDealers.sol:229-236 — critical arithmetic invariant
// uint32 max = 4,294,967,295 ≈ 4,294 USDC
// LOW threshold = 1,000,000,000 = 1,000 USDC < uint32_max (reachable)
// MID threshold = 10,000,000,000 = 10,000 USDC > uint32_max (unreachable as boundary)
function _calculateFees(uint256 _price) internal pure returns (uint256) {
if (_price <= LOW_FEE_THRESHOLD) {
return (_price * LOW_FEE_BPS) / MAX_BPS; // 1% -- reachable
} else if (_price <= MID_FEE_THRESHOLD) {
return (_price * MID_FEE_BPS) / MAX_BPS; // 3% -- partially reachable
}
return (_price * HIGH_FEE_BPS) / MAX_BPS; // 5% -- @audit UNREACHABLE via list()
}

Risk

Likelihood:

  • Every deployment of this contract has the HIGH fee tier permanently disabled from the moment it goes live — no specific user action or sequence of calls is required; this is a static type constraint

  • Any seller attempting to list above ~4,294 USDC receives a confusing ABI-level revert with no on-chain explanation, permanently excluding all high-value NFT listings

Impact:

  • The protocol under-collects fees on all listings in the 1,000–4,294 USDC range: fee rate is 3% (MID) instead of the intended 5% (HIGH) — a shortfall of up to ~85.90 USDC per sale, totalling ~85,900 USDC in foregone revenue at 1,000 sales

  • The protocol's total addressable market is hard-capped at ~4,294 USDC per NFT — sellers of high-value NFTs have no path to list through this protocol

Proof of Concept

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.34;
import {Test, console2} from "forge-std/Test.sol";
import {NFTDealers} from "../src/NFTDealers.sol";
import {MockUSDC} from "../src/MockUSDC.sol";
/**
* @title PoC_M2_Uint32PriceCap
* @notice Proof of Concept: M-2 -- Listing.price and list()/_price are typed
* uint32. uint32 max = 4,294,967,295 raw units ~= 4,294 USDC (6 dp).
* MID_FEE_THRESHOLD = 10,000e6 = 10,000,000,000 which exceeds uint32
* max. Consequently the HIGH fee tier (5% on prices > 10,000 USDC) is
* permanently dead code -- no listing can ever reach it.
*
* ROOT CAUSE:
* NFTDealers.sol:56 uint32 price; in Listing struct
* NFTDealers.sol:127 function list(uint256 _tokenId, uint32 _price)
* NFTDealers.sol:185 function updatePrice(uint256 _listingId, uint32 _newPrice)
*
* The fee schedule (_calculateFees) uses uint256 thresholds that cannot be
* reached through uint32 price inputs:
*
* uint32_max = 4,294,967,295 (~4,294 USDC)
* LOW_FEE_THRESHOLD = 1,000,000,000 ( 1,000 USDC) -- reachable
* MID_FEE_THRESHOLD = 10,000,000,000 (10,000 USDC) -- EXCEEDS uint32 max
*
* Because uint32_max < MID_FEE_THRESHOLD, every listing price sits in either
* the LOW (1%) or MID (3%) tier. The HIGH (5%) tier is unreachable code.
*
* Secondary effect: any attempt to list at a price > uint32_max (e.g. a
* 10,000 USDC or 100 ETH NFT) reverts at the ABI decoder level, because
* the EVM's Solidity ABI validator requires that a uint32 slot in calldata
* have its upper 224 bits zeroed.
*
* THREE TESTS:
*
* testM2_HighFeeTierNeverReachable
* The highest valid listing price (type(uint32).max) is computed and its
* fee calculated. It falls in the MID (3%) tier, not HIGH (5%). The test
* also proves the arithmetic invariant: uint32_max < MID_FEE_THRESHOLD,
* so no uint32 value can ever trigger the HIGH tier.
*
* testM2_ListRevertsAboveUint32Max
* A low-level call encodes a price of 5,000e6 (5,000 USDC, which would be
* HIGH tier) as a full 256-bit value in the uint32 parameter slot.
* The Solidity ABI decoder rejects it and the transaction reverts.
* Legitimate high-value NFT listings are impossible in this protocol.
*
* testM2_ExistingTestValidatesDeadCode
* calculateFees(15_500e6) returns 5% fees (HIGH tier). This value is
* 15,500,000,000 raw units, which is ~3.6x uint32_max and cannot be stored
* in Listing.price or passed to list(). The NFTDealersTest.testCalculateFeesHigh
* test passes, but validates a code path that real protocol usage can never
* exercise. The 5% fee rate is a dead letter in any on-chain scenario.
*/
contract PoC_M2_Uint32PriceCap is Test {
NFTDealers public nftDealers;
MockUSDC public usdc;
string internal constant BASE_IMAGE =
"https://images.unsplash.com/photo-1541781774459-bb2af2f05b55";
address public owner = makeAddr("owner");
address public seller = makeAddr("seller");
uint256 public constant LOCK_AMOUNT = 20e6;
// Fee schedule constants (mirrors NFTDealers internals)
uint256 public constant LOW_FEE_THRESHOLD = 1_000e6; // 1,000 USDC
uint256 public constant MID_FEE_THRESHOLD = 10_000e6; // 10,000 USDC
uint32 public constant LOW_FEE_BPS = 100; // 1%
uint32 public constant MID_FEE_BPS = 300; // 3%
uint32 public constant HIGH_FEE_BPS = 500; // 5%
uint32 public constant MAX_BPS = 10_000;
// The highest price that can ever be stored in Listing.price
uint256 public constant UINT32_MAX_PRICE = type(uint32).max; // 4,294,967,295
// A price that would sit in the HIGH tier -- exceeds uint32 max
uint256 public constant HIGH_TIER_PRICE = 15_500e6; // 15,500,000,000 raw
function setUp() public {
usdc = new MockUSDC();
nftDealers = new NFTDealers(
owner, address(usdc), "NFTDealers", "NFTD", BASE_IMAGE, LOCK_AMOUNT
);
vm.prank(owner);
nftDealers.revealCollection();
vm.prank(owner);
nftDealers.whitelistWallet(seller);
usdc.mint(seller, LOCK_AMOUNT);
}
// =========================================================================
// TEST 1: HIGH fee tier can never be reached via list()
// =========================================================================
/**
* @notice The maximum uint32 price (type(uint32).max = 4,294,967,295) is
* below MID_FEE_THRESHOLD (10,000,000,000). Therefore every listing
* ever created through this protocol applies either the 1% or 3%
* fee rate. The 5% HIGH tier is dead code.
*
* This test also lists an NFT at type(uint32).max and confirms the
* stored price and fee tier match expectations.
*/
function testM2_HighFeeTierNeverReachable() public {
console2.log("=== M-2: HIGH FEE TIER UNREACHABLE ===");
console2.log("[0] uint32 max price (raw) :", UINT32_MAX_PRICE);
console2.log(" uint32 max price (USDC) :", UINT32_MAX_PRICE / 1e6, "USDC");
console2.log(" LOW_FEE_THRESHOLD (USDC) : 1000 USDC");
console2.log(" MID_FEE_THRESHOLD (USDC) : 10000 USDC");
console2.log(" uint32_max < MID_FEE_THRESHOLD:", UINT32_MAX_PRICE < MID_FEE_THRESHOLD);
// Maximum price sits between LOW and MID thresholds -- MID tier (3%)
uint256 feesAtMaxPrice = (UINT32_MAX_PRICE * MID_FEE_BPS) / MAX_BPS;
console2.log("[1] Fees at uint32_max price:");
console2.log(" Applicable tier : MID (3%)");
console2.log(" Fee amount (raw) :", feesAtMaxPrice);
console2.log(" Fee amount (USDC) :", feesAtMaxPrice / 1e6);
// Confirm via calculateFees
uint256 reportedFees = nftDealers.calculateFees(UINT32_MAX_PRICE);
assertEq(reportedFees, feesAtMaxPrice,
"Max-price fees are MID tier (3%), not HIGH (5%)");
// Show what HIGH tier would compute -- but only reachable via uint256
uint256 feesIfHighTier = (UINT32_MAX_PRICE * HIGH_FEE_BPS) / MAX_BPS;
console2.log("[2] HIGH-tier fee at uint32_max :", feesIfHighTier / 1e6, "USDC (never applied)");
console2.log(" Revenue difference per sale :", (feesIfHighTier - feesAtMaxPrice) / 1e6, "USDC lost vs intended");
// ── Mint + list at uint32_max price ──────────────────────────────────
// Fund seller for this price (price itself is not required upfront;
// buyer pays at buy time -- we just prove the price is accepted)
vm.startPrank(seller);
usdc.approve(address(nftDealers), LOCK_AMOUNT);
nftDealers.mintNft();
uint256 tokenId = nftDealers.totalMinted();
nftDealers.list(tokenId, type(uint32).max); // max uint32: accepted by list()
vm.stopPrank();
(, uint32 storedPrice, , , bool isActive) = nftDealers.s_listings(tokenId);
console2.log("[3] NFT listed at uint32_max:");
console2.log(" Stored price (raw) :", storedPrice);
console2.log(" isActive :", isActive);
console2.log(" Fee tier applied : MID 3% (HIGH tier never triggered)");
// ── Assertions ────────────────────────────────────────────────────────
assertTrue(UINT32_MAX_PRICE < MID_FEE_THRESHOLD,
"INVARIANT: uint32_max < MID_FEE_THRESHOLD -- HIGH tier permanently unreachable");
assertEq(storedPrice, type(uint32).max,
"Max uint32 price stored correctly");
assertTrue(isActive, "Listing active at max price");
// Fees at max price are MID (3%), not HIGH (5%)
assertEq(nftDealers.calculateFees(storedPrice), (uint256(storedPrice) * MID_FEE_BPS) / MAX_BPS,
"CRITICAL: max-price listing uses MID (3%) fee -- HIGH (5%) tier is dead code");
}
// =========================================================================
// TEST 2: list() reverts for any HIGH-tier price (> uint32 max)
// =========================================================================
/**
* @notice A low-level call encodes a price of 5,000 USDC (5,000,000,000
* raw units) as a 256-bit integer in the uint32 parameter slot.
* 5,000,000,000 > uint32_max = 4,294,967,295, so the Solidity ABI
* decoder rejects the calldata and the transaction reverts.
*
* Any NFT whose value exceeds ~4,294 USDC cannot be listed through
* this protocol. Sellers wanting to list high-value NFTs are
* completely excluded.
*/
function testM2_ListRevertsAboveUint32Max() public {
console2.log("\n=== M-2: list() REVERTS ABOVE uint32 MAX ===");
// Mint a token so we have a valid tokenId to attempt listing
vm.startPrank(seller);
usdc.approve(address(nftDealers), LOCK_AMOUNT);
nftDealers.mintNft();
vm.stopPrank();
uint256 tokenId = nftDealers.totalMinted();
// A 5,000 USDC price -- reasonable for an NFT, but > uint32_max
uint256 fiveThousandUsdc = 5_000e6; // = 5,000,000,000 > 4,294,967,295
console2.log("[1] Attempting to list at 5,000 USDC:");
console2.log(" Price (raw) :", fiveThousandUsdc);
console2.log(" uint32 max (raw) :", uint256(type(uint32).max));
console2.log(" Exceeds uint32 max :", fiveThousandUsdc > type(uint32).max);
// Encode the call with the full uint256 value in the uint32 price slot.
// The Solidity ABI decoder validates uint32 inputs have high bits zeroed.
bytes memory callData = abi.encodeWithSelector(
NFTDealers.list.selector,
tokenId,
fiveThousandUsdc // encoded as uint256 in the uint32 slot
);
vm.prank(seller);
(bool success, ) = address(nftDealers).call(callData);
console2.log("[2] list() with 5,000 USDC price succeeded:", success);
console2.log(" (false = reverted as expected)");
// A 10,000 USDC price -- the minimum HIGH-tier price
uint256 tenThousandUsdc = 10_001e6; // = 10,001,000,000 >> uint32_max
bytes memory callData2 = abi.encodeWithSelector(
NFTDealers.list.selector,
tokenId,
tenThousandUsdc
);
vm.prank(seller);
(bool success2, ) = address(nftDealers).call(callData2);
console2.log("[3] list() with 10,001 USDC price succeeded:", success2);
console2.log(" HIGH-tier NFT listing permanently impossible.");
// ── Assertions ────────────────────────────────────────────────────────
assertFalse(success,
"CRITICAL: list() reverts for 5,000 USDC price -- high-value NFT listing impossible");
assertFalse(success2,
"CRITICAL: list() reverts for 10,001 USDC price -- HIGH fee tier inaccessible");
// Confirm the NFT remains unlisted (no listing was created)
(, , , , bool isActive) = nftDealers.s_listings(tokenId);
assertFalse(isActive,
"NFT unlisted -- no listing was created at the over-range price");
console2.log("[4] Seller's NFT remains unlisted. No listing at s_listings[tokenId].");
console2.log(" Protocol cannot serve any NFT valued above ~4,294 USDC.");
}
// =========================================================================
// TEST 3: Existing test suite validates unreachable code
// =========================================================================
/**
* @notice calculateFees(15_500e6) correctly applies the 5% HIGH tier, and
* NFTDealersTest.testCalculateFeesHigh passes against it.
* But 15_500e6 = 15,500,000,000 raw units is ~3.6x uint32_max and
* can never appear as a listing price -- list() and updatePrice()
* both declare _price as uint32.
*
* The public calculateFees(uint256) function accepts any uint256,
* making the HIGH-tier test path reachable in isolation. This
* creates a false assurance: the 5% fee logic is verified by tests
* but is permanently inert in production.
*/
function testM2_ExistingTestValidatesDeadCode() public {
console2.log("\n=== M-2: PUBLIC calculateFees() VALIDATES DEAD CODE ===");
// This call succeeds -- calculateFees takes uint256
uint256 highTierFees = nftDealers.calculateFees(HIGH_TIER_PRICE);
uint256 expectedHighTierFees = (HIGH_TIER_PRICE * HIGH_FEE_BPS) / MAX_BPS;
console2.log("[1] calculateFees(15_500e6) =", highTierFees / 1e6, "USDC");
console2.log(" = 5% of 15,500 USDC = 775 USDC (HIGH tier applied)");
console2.log(" This is what testCalculateFeesHigh validates.");
console2.log(" HIGH_TIER_PRICE (raw) :", HIGH_TIER_PRICE);
console2.log(" uint32 max (raw) :", uint256(type(uint32).max));
console2.log(" HIGH_TIER_PRICE > uint32_max :", HIGH_TIER_PRICE > type(uint32).max);
console2.log(" Conclusion: this price CANNOT be stored in Listing.price");
// Show the three fee tiers and which is reachable
uint256 feesLow = nftDealers.calculateFees(500e6); // 500 USDC -- LOW tier
uint256 feesMid = nftDealers.calculateFees(2_000e6); // 2,000 USDC -- MID tier
uint256 feesHigh = nftDealers.calculateFees(15_500e6); // unreachable via list()
console2.log("[2] Fee tier availability via list():");
console2.log(" LOW (1%) at 500 USDC:", feesLow / 1e6, "USDC fee -- REACHABLE");
console2.log(" MID (3%) at 2000 USDC:", feesMid / 1e6, "USDC fee -- REACHABLE");
console2.log(" HIGH (5%) at 15500 USDC:", feesHigh / 1e6,"USDC fee -- UNREACHABLE (price > uint32 max)");
// ── Assertions ────────────────────────────────────────────────────────
assertEq(highTierFees, expectedHighTierFees,
"calculateFees returns correct HIGH-tier result for uint256 input");
assertTrue(HIGH_TIER_PRICE > type(uint32).max,
"HIGH_TIER_PRICE exceeds uint32 max -- cannot be set via list()");
assertTrue(uint256(type(uint32).max) < MID_FEE_THRESHOLD,
"INVARIANT: no uint32 price can reach HIGH tier -- dead code confirmed");
// The MID tier IS reachable (2000 USDC < uint32_max)
assertLt(uint256(2_000e6), uint256(type(uint32).max),
"MID tier is reachable -- prices between 1000-4294 USDC use 3%");
console2.log("[3] IMPACT: Protocol earns 3% max instead of 5% on high-value listings.");
console2.log(" High-value NFT market is completely excluded (revert above uint32_max).");
console2.log(" testCalculateFeesHigh passes but tests an inert code branch.");
}
}

POC RESULT

forge test --match-contract PoC_M2_Uint32PriceCap -vv
[⠒] Compiling...
[⠒] Compiling 1 files with Solc 0.8.34
[⠢] Solc 0.8.34 finished in 369.66ms
Compiler run successful with warnings:
Warning (2018): Function state mutability can be restricted to view
--> test/PoC_M2_Uint32PriceCap.t.sol:260:5:
|
260 | function testM2_ExistingTestValidatesDeadCode() public {
| ^ (Relevant source part starts here and spans across multiple lines).
Ran 3 tests for test/PoC_M2_Uint32PriceCap.t.sol:PoC_M2_Uint32PriceCap
[PASS] testM2_ExistingTestValidatesDeadCode() (gas: 32845)
Logs:
=== M-2: PUBLIC calculateFees() VALIDATES DEAD CODE ===
[1] calculateFees(15_500e6) = 775 USDC
= 5% of 15,500 USDC = 775 USDC (HIGH tier applied)
This is what testCalculateFeesHigh validates.
HIGH_TIER_PRICE (raw) : 15500000000
uint32 max (raw) : 4294967295
HIGH_TIER_PRICE > uint32_max : true
Conclusion: this price CANNOT be stored in Listing.price
[2] Fee tier availability via list():
LOW (1%) at 500 USDC: 5 USDC fee -- REACHABLE
MID (3%) at 2000 USDC: 60 USDC fee -- REACHABLE
HIGH (5%) at 15500 USDC: 775 USDC fee -- UNREACHABLE (price > uint32 max)
[3] IMPACT: Protocol earns 3% max instead of 5% on high-value listings.
High-value NFT market is completely excluded (revert above uint32_max).
testCalculateFeesHigh passes but tests an inert code branch.
[PASS] testM2_HighFeeTierNeverReachable() (gas: 274157)
Logs:
=== M-2: HIGH FEE TIER UNREACHABLE ===
[0] uint32 max price (raw) : 4294967295
uint32 max price (USDC) : 4294 USDC
LOW_FEE_THRESHOLD (USDC) : 1000 USDC
MID_FEE_THRESHOLD (USDC) : 10000 USDC
uint32_max < MID_FEE_THRESHOLD: true
[1] Fees at uint32_max price:
Applicable tier : MID (3%)
Fee amount (raw) : 128849018
Fee amount (USDC) : 128
[2] HIGH-tier fee at uint32_max : 214 USDC (never applied)
Revenue difference per sale : 85 USDC lost vs intended
[3] NFT listed at uint32_max:
Stored price (raw) : 4294967295
isActive : true
Fee tier applied : MID 3% (HIGH tier never triggered)
[PASS] testM2_ListRevertsAboveUint32Max() (gas: 179390)
Logs:
=== M-2: list() REVERTS ABOVE uint32 MAX ===
[1] Attempting to list at 5,000 USDC:
Price (raw) : 5000000000
uint32 max (raw) : 4294967295
Exceeds uint32 max : true
[2] list() with 5,000 USDC price succeeded: false
(false = reverted as expected)
[3] list() with 10,001 USDC price succeeded: false
HIGH-tier NFT listing permanently impossible.
[4] Seller's NFT remains unlisted. No listing at s_listings[tokenId].
Protocol cannot serve any NFT valued above ~4,294 USDC.
Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 879.77µs (479.99µs CPU time)
Ran 1 test suite in 10.35ms (879.77µs CPU time): 3 tests passed, 0 failed, 0 skipped (3 total tests)

Recommended Mitigation

Change Listing.price, list()._price, and updatePrice()._newPrice from uint32 to uint96. uint96 is preferred over uint256 in the struct because it packs with bool isActive and address seller in a single EVM slot, avoiding storage overhead — and its maximum (~79 billion USDC) covers all realistic NFT prices:

struct Listing {
address seller;
- uint32 price;
+ uint96 price; // uint96 max = ~79 billion USDC at 6dp
address nft;
uint256 tokenId;
bool isActive;
}
-function list(uint256 _tokenId, uint32 _price) external onlyWhitelisted {
+function list(uint256 _tokenId, uint96 _price) external onlyWhitelisted {
...
}
-function updatePrice(uint256 _listingId, uint32 _newPrice) external onlySeller(_listingId) {
+function updatePrice(uint256 _listingId, uint96 _newPrice) external onlySeller(_listingId) {
...
}

Support

FAQs

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

Give us feedback!