NFT Dealers

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

price stored as unit.32 silently truncates to high values

Author Revealed upon completion

The Listing struct stores price as uint32. USDC uses 6 decimal places, so uint32 max (~4.29B) represents only ~4,294 USDC. Any price above this silently wraps/truncates on assignment, corrupting the listing price. Additionally, the fee tiers for MID (10,000 USDC) and HIGH (>10,000 USDC) are completely unreachable since no uint32 value can represent those thresholds.

struct Listing {
address seller;
uint32 price; // @> max 4,294,967,295 = ~4,294 USDC (6 decimals)
address nft;
uint256 tokenId;
bool isActive;
}
uint256 private constant MID_FEE_THRESHOLD = 10_000e6; // @> 10,000 USDC — unreachable via uint32
uint256 private constant HIGH_FEE_BPS = 500; //
function list(uint256 _tokenId, uint32 _price) external onlyWhitelisted { // @> truncation happens at callsite
function updatePrice(uint256 _listingId, uint32 _newPrice) external ... // @> same issue

Risk

Likelihood:

  • Any seller attempting to list an NFT above ~4,294 USDC triggers silent truncation — the price stored will be a completely wrong value with no revert or warning

  • The protocol is designed with 3 fee tiers implying high-value sales were intended, making this scenario near-certain in production

Impact:

  • Sellers receive drastically less USDC than intended — a 50,000 USDC listing may silently become a ~782 USDC listing after truncation

  • The MID and HIGH fee tiers (3% and 5%) are permanently dead code — all sales are charged the LOW 1% fee regardless of price, reducing protocol revenue

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.34;
import "forge-std/Test.sol";
import "../src/NFTDealers.sol";
contract PriceTruncationTest is Test {
NFTDealers public nftDealers;
address public owner = address(0x1);
address public seller = address(0x2);
address public buyer = address(0x3);
address public usdcHolder = address(0x4);
IERC20 public usdc;
uint256 constant LOCK_AMOUNT = 20 * 10**6; // 20 USDC
uint256 constant MAX_UINT32 = 4_294_967_295; // 2^32 - 1
// Test prices (in USDC with 6 decimals)
uint256 public constant PRICE_5K_USDC = 5_000 * 10**6; // 5,000,000,000
uint256 public constant PRICE_10K_USDC = 10_000 * 10**6; // 10,000,000,000
uint256 public constant PRICE_50K_USDC = 50_000 * 10**6; // 50,000,000,000
uint256 public constant PRICE_100K_USDC = 100_000 * 10**6; // 100,000,000,000
uint256 public constant PRICE_1M_USDC = 1_000_000 * 10**6; // 1,000,000,000,000
event Debug(string message, uint256 value);
function setUp() public {
// Deploy USDC mock
usdc = IERC20(address(new ERC20Mock("USDC", "USDC", 6)));
// Deploy NFTDealers
nftDealers = new NFTDealers(
owner,
address(usdc),
"Test Collection",
"TEST",
"ipfs://image/",
LOCK_AMOUNT
);
// Setup
vm.startPrank(owner);
nftDealers.whitelistWallet(seller);
nftDealers.revealCollection();
vm.stopPrank();
// Give USDC to seller and mint NFT
deal(address(usdc), seller, 1_000_000 * 10**6);
vm.startPrank(seller);
usdc.approve(address(nftDealers), LOCK_AMOUNT);
nftDealers.mintNft(); // mints tokenId 1
vm.stopPrank();
}
function testPriceTruncation() public {
console.log("=== Price Truncation Demonstration ===");
console.log("uint32 max value:", MAX_UINT32);
console.log("uint32 max USDC (with 6 decimals):", MAX_UINT32 / 10**6, "USDC\n");
// Test Case 1: Price within uint32 range
console.log("Test 1: Price within uint32 range (5,000 USDC)");
testListingPrice(PRICE_5K_USDC, "5,000 USDC");
// Test Case 2: Price just above uint32 max
console.log("\nTest 2: Price above uint32 max (50,000 USDC)");
testListingPrice(PRICE_50K_USDC, "50,000 USDC");
// Test Case 3: Price significantly above uint32 max
console.log("\nTest 3: Price far above uint32 max (100,000 USDC)");
testListingPrice(PRICE_100K_USDC, "100,000 USDC");
// Test Case 4: Extreme price
console.log("\nTest 4: Extreme price (1,000,000 USDC)");
testListingPrice(PRICE_1M_USDC, "1,000,000 USDC");
}
function testListingPrice(uint256 intendedPrice, string memory priceLabel) internal {
// Try to cast to uint32 (what happens in the list function)
uint32 truncatedPrice = uint32(intendedPrice);
uint256 actualStoredPrice = uint256(truncatedPrice);
console.log("Intended price:", intendedPrice, "(", priceLabel, ")");
console.log("As uint32 (truncated):", truncatedPrice);
console.log("Actual stored value:", actualStoredPrice);
console.log("Actual USDC value:", actualStoredPrice / 10**6, "USDC");
// Calculate truncation mathematically
uint256 expectedTruncation = intendedPrice % (MAX_UINT32 + 1);
console.log("Expected truncation:", expectedTruncation);
assertEq(actualStoredPrice, expectedTruncation, "Truncation calculation mismatch");
// Demonstrate the actual listing
vm.startPrank(seller);
// List the NFT with the intended price (silently truncates)
nftDealers.list(1, uint32(intendedPrice));
// Check what price was actually stored
(, uint32 storedPrice, , , bool isActive) = nftDealers.s_listings(1);
console.log("\nListing created with:");
console.log(" Stored price (uint32):", storedPrice);
console.log(" Stored price in USDC:", storedPrice / 10**6, "USDC");
console.log(" Seller intended:", intendedPrice / 10**6, "USDC");
// Verify the truncation occurred
assertEq(storedPrice, uint32(intendedPrice), "Price was truncated");
assertLt(storedPrice / 10**6, intendedPrice / 10**6, "Price should be less than intended");
// Cancel listing for next test
nftDealers.cancelListing(1);
vm.stopPrank();
console.log("---");
}
function testFeeTierUnreachable() public {
console.log("\n=== Fee Tier Unreachability Demonstration ===");
// Show the fee thresholds
uint256 lowThreshold = 1000e6; // 1,000 USDC
uint256 midThreshold = 10000e6; // 10,000 USDC
uint256 highThreshold = 10001e6; // >10,000 USDC
console.log("Fee Tiers (as designed):");
console.log(" LOW (1%): up to", lowThreshold / 10**6, "USDC");
console.log(" MID (3%):", lowThreshold / 10**6, "-", midThreshold / 10**6, "USDC");
console.log(" HIGH (5%): above", midThreshold / 10**6, "USDC\n");
console.log("Maximum price achievable with uint32:", MAX_UINT32 / 10**6, "USDC\n");
// Try to reach MID fee tier
console.log("Attempting to reach MID fee tier (10,000 USDC)...");
// What seller thinks they're setting
uint256 intendedMidPrice = midThreshold; // 10,000 USDC
// What actually gets stored
uint32 truncatedMidPrice = uint32(intendedMidPrice);
console.log(" Intended price:", intendedMidPrice / 10**6, "USDC");
console.log(" Actual stored:", truncatedMidPrice / 10**6, "USDC");
// Calculate fees
vm.startPrank(seller);
nftDealers.list(1, uint32(intendedMidPrice));
// Check actual stored price
(, uint32 storedPrice, , , ) = nftDealers.s_listings(1);
// Calculate fees using internal calculation
uint256 fees = nftDealers.calculateFees(storedPrice);
uint256 expectedFeesLow = (storedPrice * 100) / 10000; // 1%
console.log(" Actual stored price:", storedPrice / 10**6, "USDC");
console.log(" Fees charged:", fees / 10**6, "USDC");
console.log(" Fee rate:", (fees * 10000) / storedPrice, "bps (should be 100 for 1%)");
// Verify it's using LOW fee tier (1%) instead of MID (3%)
assertEq(fees, expectedFeesLow, "Should charge 1% fee, not 3%");
vm.stopPrank();
}
function testPracticalAttack() public {
console.log("\n=== Practical Impact Demonstration ===");
// Setup: Another user with USDC
address victim = address(0x5);
deal(address(usdc), victim, 1_000_000 * 10**6);
// Seller lists what they think is a 50,000 USDC NFT
vm.startPrank(seller);
nftDealers.list(1, uint32(PRICE_50K_USDC));
(, uint32 storedPrice, , , ) = nftDealers.s_listings(1);
vm.stopPrank();
console.log("Seller intends to sell for: 50,000 USDC");
console.log("Actual listing price:", storedPrice / 10**6, "USDC");
console.log("Price difference: 50,000 -", storedPrice / 10**6, "=", 50000 - storedPrice / 10**6, "USDC LOST\n");
// Buyer sees a bargain
vm.startPrank(victim);
usdc.approve(address(nftDealers), storedPrice);
// Buyer pays the truncated price
uint256 victimBalanceBefore = usdc.balanceOf(victim);
nftDealers.buy(1);
uint256 victimBalanceAfter = usdc.balanceOf(victim);
console.log("Buyer paid:", (victimBalanceBefore - victimBalanceAfter) / 10**6, "USDC");
console.log("Buyer acquired a NFT they thought was worth 50,000 USDC for only",
(victimBalanceBefore - victimBalanceAfter) / 10**6, "USDC");
// Seller collects proceeds
vm.stopPrank();
vm.startPrank(seller);
// Collect proceeds (need to make listing inactive first - already done by buy)
nftDealers.collectUsdcFromSelling(1);
uint256 sellerBalanceAfter = usdc.balanceOf(seller);
console.log("\nSeller received:", (sellerBalanceAfter - 1_000_000 * 10**6) / 10**6, "USDC (including collateral)");
console.log("Seller expected: ~50,000 USDC + 20 USDC collateral");
console.log("Actual loss: ~", 50000 - ((sellerBalanceAfter - 1_000_000 * 10**6) / 10**6 - 20), "USDC");
vm.stopPrank();
}
function testMathematicalExplanation() public pure {
console.log("\n=== Mathematical Explanation ===");
uint32 maxUint32 = type(uint32).max;
uint256 testPrice = 50_000e6; // 50,000,000,000
console.log("uint32 max value:", maxUint32);
console.log("Test price:", testPrice);
console.log("Truncation calculation:");
console.log(" Step 1:", testPrice, "/", uint256(maxUint32) + 1, "=", testPrice / (uint256(maxUint32) + 1), "remainder", testPrice % (uint256(maxUint32) + 1));
console.log(" Step 2:", testPrice, "% 2^32 =", testPrice % (uint256(maxUint32) + 1));
// Demonstrate with smaller numbers for clarity
console.log("\nSimple example (using uint8 for clarity):");
console.log(" uint8 max: 255");
console.log(" Trying to store 300 in uint8:");
console.log(" 300 % 256 = 44 (truncated value)");
console.log(" Same principle applies to uint32");
}
}

Recommended Mitigation

- 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) {
+

Support

FAQs

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

Give us feedback!