NFT Dealers

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

Listing Price Stored as `uint32` Silently Truncates USDC Amounts Above ~4,294 USDC, Causing Massive Buyer Fund Loss

Author Revealed upon completion

Description

  • NFT listings store a price in USDC (6 decimals). The contract defines three fee tiers: LOW (<1,000 USDC), MID (1,000–10,000 USDC), and HIGH (>10,000 USDC), expecting the
    full price range to be usable.

    • The price field in the Listing struct is declared uint32, which maxes out at 4,294,967,295 (~4,294 USDC). Any price above that silently truncates on downcast. The HIGH
      fee tier (>10,000 USDC) is completely unreachable. A seller intending to list at 10,000 USDC stores 1,410 USDC instead — the buyer pays ~$8,590 less than the seller
      expected.

// Root caus**Root Cause**: The `price` field in the `Listing` struct is declared as `uint32`. USDC uses 6 decimals, so 1 USDC = 1e6 = 1,000,000. A `uint32` has a maximum value of 4,294,967,295 (2^32 - 1), which represents approximately **4,294.97 USDC**.
The `list()` function accepts `uint32 _price`, and the `buy()` function uses `listing.price` (a `uint32`) for the USDC transfer amount. However, the fee threshold constants are defined as `uint256`: `MID_FEE_THRESHOLD = 10_000e6` and prices up to and above that are expected by the fee system. The contract's fee tiers explicitly contemplate prices up to 10,000+ USDC, but the `uint32` price field makes prices above ~4,294 USDC **impossible to represent correctly**.
When a user attempts to list at a price like 5,000 USDC (5000e6 = 5,000,000,000), this value overflows `uint32` at the Solidity call level. In Solidity 0.8.x, passing a `uint256` literal that exceeds `uint32` to a `uint32` parameter will revert at compile time for literals, but when passing a variable (like in tests using `uint32(nftPrice)` cast), values above 4,294,967,295 will be **truncated silently via unsafe downcast**.
More critically: `_calculateFees` takes `uint256` but is called with `listing.price` which is `uint32`. The fee tier boundaries (1000e6 and 10_000e6) are both well within or above `uint32` range. This means:
- The MID and HIGH fee tiers (for prices > 1000 USDC and > 10,000 USDC) can never be properly reached since `uint32` maxes out at ~4,294 USDC.
- Prices at the LOW_FEE_THRESHOLD (1000e6 = 1,000,000,000) DO fit in uint32.
- Prices at the MID_FEE_THRESHOLD (10,000e6 = 10,000,000,000) do NOT fit in uint32.
The HIGH_FEE tier is completely unreachable. The MID_FEE tier is only partially reachable (1000-4294 USDC). Any attempt to list above ~4294 USDC will either revert or silently truncate.e in the codebase with @> marks to highlight the relevant section

Risk

  • The MID and HIGH fee tiers (for prices > 1000 USDC and > 10,000 USDC) can never be properly reached since uint32 maxes out at ~4,294 USDC.

  • Prices at the LOW_FEE_THRESHOLD (1000e6 = 1,000,000,000) DO fit in uint32.

  • Prices at the MID_FEE_THRESHOLD (10,000e6 = 10,000,000,000) do NOT fit in uint32.

The HIGH_FEE tier is completely unreachable. The MID_FEE tier is only partially reachable (1000-4294 USDC). Any attempt to list above ~4294 USDC will either revert or silently truncate.

Impact:

  • . If a seller lists an NFT and somehow a truncated price is stored (e.g., through an interface or contract that casts to uint32):

    • Seller intends price of 5,000 USDC (5000e6), but after uint32 truncation, stored price becomes 5000e6 % 2^32 = 705,032,704 (~705 USDC).

    • Buyer pays only ~705 USDC instead of 5,000 USDC.

    • Seller loses ~4,295 USDC of expected payment.

    1. The entire fee tier system above ~4,294 USDC is non-functional. The HIGH_FEE_BPS (5%) tier at >10,000 USDC can never be reached, meaning the protocol permanently loses the higher fee revenue it was designed to collect.

    2. Even for valid uint32 prices, the buy() function charges listing.price as a uint32 value. When this is passed to usdc.transferFrom, it's implicitly upcast to uint256 -- this part works correctly for values within uint32 range. But the entire design is fundamentally broken for the price ranges the protocol intends to support.

Proof of Concept

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.34;
import {Test, console} from "forge-std/Test.sol";
import {NFTDealers} from "../src/NFTDealers.sol";
import {MockUSDC} from "../src/MockUSDC.sol";
contract NFTDC003Test is Test {
NFTDealers public nftDealers;
MockUSDC public usdc;
address public owner = makeAddr("owner");
address public alice = makeAddr("alice");
address public bob = makeAddr("bob");
function setUp() public {
usdc = new MockUSDC();
nftDealers = new NFTDealers(owner, address(usdc), "NFTDealers", "NFTD", "img", 20e6);
vm.prank(owner);
nftDealers.revealCollection();
vm.prank(owner);
nftDealers.whitelistWallet(alice);
usdc.mint(alice, 1000e6);
usdc.mint(bob, 100_000e6);
}
function test_NFTDC003_Uint32PriceTruncation() public {
// EXPLOIT: Demonstrate that the HIGH_FEE tier is unreachable
// MID_FEE_THRESHOLD = 10_000e6 = 10,000,000,000 which is > uint32 max (4,294,967,295)
uint256 highPrice = 10_000e6; // 10,000 USDC - intended for HIGH_FEE tier
uint32 truncatedPrice = uint32(highPrice); // Silent truncation!
// The truncated value is completely wrong
assertFalse(uint256(truncatedPrice) == highPrice, "Price was truncated - HIGH_FEE tier unreachable");
// Show the actual truncated value
// 10_000e6 = 10,000,000,000
// 10,000,000,000 mod 2^32 = 10,000,000,000 - 4,294,967,296 = 5,705,032,704
// But wait, 5,705,032,704 > 2^32, so: 5,705,032,704 - 4,294,967,296 = 1,410,065,408
// Actually: 10,000,000,000 % 4,294,967,296 = 1,410,065,408
uint256 expectedTruncated = highPrice % (uint256(type(uint32).max) + 1);
assertEq(uint256(truncatedPrice), expectedTruncated, "Truncation confirmed");
// This means a 10,000 USDC listing becomes a ~1,410 USDC listing!
// Buyer pays ~1,410 USDC instead of 10,000 USDC
// Seller loses ~8,590 USDC
// Also demonstrate that even 5,000 USDC (mid-tier) truncates
uint256 midPrice = 5000e6; // 5,000,000,000
uint32 truncatedMid = uint32(midPrice);
// 5,000,000,000 % 4,294,967,296 = 705,032,704 (~705 USDC)
assertFalse(uint256(truncatedMid) == midPrice, "Mid-range price also truncated");
}
function test_NFTDC003_MaxValidPrice() public {
// Show that the maximum price that can be correctly stored is ~4,294 USDC
uint32 maxUint32 = type(uint32).max; // 4,294,967,295
// In USDC terms: 4,294,967,295 / 1e6 = ~4,294.97 USDC
// This means:
// - LOW_FEE tier (<=1000 USDC): Works correctly
// - MID_FEE tier (1000-10000 USDC): Only works up to ~4294 USDC
// - HIGH_FEE tier (>10000 USDC): COMPLETELY UNREACHABLE
// The fee calculation accepts uint256 but listing.price is uint32
// So _calculateFees will never see a value > 4,294,967,295
// which is always < MID_FEE_THRESHOLD (10,000,000,000)
uint256 maxPossibleFee = nftDealers.calculateFees(uint256(maxUint32));
// At max uint32 value, we're in the MID tier (between 1000e6 and 10000e6)
// Fee = 4,294,967,295 * 300 / 10000 = 128,849,018 (~128.85 USDC at 3%)
uint256 expectedFee = (uint256(maxUint32) * 300) / 10_000;
assertEq(maxPossibleFee, expectedFee, "Max price always falls in MID tier");
// HIGH tier fee would be 5% but can never be charged
// Protocol permanently loses 2% fee revenue on what should be high-value sales
assertTrue(uint256(maxUint32) < 10_000e6, "uint32 max is below HIGH_FEE_THRESHOLD - tier unreachable");
}
}

Recommendation:
Change the price field in the Listing struct from uint32 to uint256, and update all function signatures accordingly:

struct Listing {
address seller;
- uint32 price;
+ uint256 price;
address nft;
uint256 tokenId;
bool isActive;
}
-function list(uint256 _tokenId, uint32 _price) external onlyWhitelisted {
+function list(uint256 _tokenId, uint256 _price) external onlyWhitelisted {
-function updatePrice(uint256 _listingId, uint32 _newPrice) external onlySeller(_listingId) {
+function updatePrice(uint256 _listingId, uint256 _newPrice) external onlySeller(_listingId) {
- remove this code
+ add this code

Support

FAQs

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

Give us feedback!