NFT Dealers

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

Silent uint32 Price Truncation on function `list()` Allows Severe Underpricing of Listings

Author Revealed upon completion

Root + Impact

Description

  • When a seller lists an NFT, the marketplace should preserve the intended listing price exactly, and buy() should charge that exact amount from the buyer.

  • Listing price is handled as uint32 in the Listing struct and in list(). Any upstream conversion from a larger integer to uint32 truncates silently modulo 232232, which can reduce a very large intended price to a much smaller value . As a result, buyers can purchase NFTs for far less than the seller intended, breaking pricing integrity and marketplace economics.

struct Listing {
address seller;
@> uint32 price;
address nft;
uint256 tokenId;
bool isActive;
}
@> function list(uint256 _tokenId, uint32 _price) external onlyWhitelisted {
require(_price >= MIN_PRICE, "Price must be at least 1 USDC");
require(ownerOf(_tokenId) == msg.sender, "Not owner of NFT");
require(s_listings[_tokenId].isActive == false, "NFT is already listed");
// @audit-info check inutile
require(_price > 0, "Price must be greater than 0");
listingsCounter++;
activeListingsCounter++;
s_listings[_tokenId] =
Listing({seller: msg.sender, price: _price, nft: address(this), tokenId: _tokenId, isActive: true});
emit NFT_Dealers_Listed(msg.sender, listingsCounter);
}

Risk

Likelihood:

  • Reason 1: This occurs whenever a listing price is provided through a wider integer domain (e.g., off-chain UI/backend uint256) and then cast/stored into uint32, causing silent modulo truncation on values above 2^32 - 1.

  • Reason 2: This occurs in normal marketplace operation because buy() trusts the stored listing.price value directly, so any truncated price is treated as canonical sale price without additional sanity checks or max-bound validation.

Impact:

  • Impact 1: NFTs can be sold at a drastically lower amount than intended by the seller (severe underpricing), creating direct economic loss for listers.

Proof of Concept

This test uses the same setup as the NFTDealersTest file.

The vulnerability: Since Solidity 0.8.x only protects against arithmetic overflow but not explicit downcasting, a seller can pass a uint256 value that silently truncates when cast to uint32. In this test, price = type(uint32).max + 1 + 1e6 truncates to exactly 1e6 (1 USDC), while the seller intended to list a much larger amount.

The exploit: A buyer purchases the NFT paying only 1 USDC instead of the intended price, acquiring the NFT at a near-zero cost.

Final assertions:

  • type(uint32).max is below MID_FEE_THRESHOLD, proving that the uint32 type structurally prevents the mid and high fee brackets from ever being reached, confirming that price must be refactored to uint256

  • Ownership of the NFT is transferred to userWithCash

  • userWithCash balance decreased by only 1 USDC instead of the actual intended price

modifier revealed() {
vm.prank(owner);
nftDealers.revealCollection();
_;
}
function testPriceParamsOnListingSilentlyOverflow() public revealed {
uint256 ONE_USDC = 1e6;
uint256 price = uint256(type(uint32).max) + 1 + ONE_USDC; // 2^32, overflows to 1USDC when casted to uint32
uint256 userWithCashInitialBalance = usdc.balanceOf(userWithCash);
vm.prank(owner);
nftDealers.whitelistWallet(userWithEvenMoreCash);
vm.startPrank(userWithEvenMoreCash);
usdc.approve(address(nftDealers), 20e6);
nftDealers.mintNft();
nftDealers.list(1, uint32(price));
vm.stopPrank();
vm.startPrank(userWithCash);
usdc.approve(address(nftDealers), ONE_USDC);
nftDealers.buy(1);
vm.stopPrank();
assert(type(uint32).max < 10_000e6); // 10_000 USDC is the mid fee threshold but is private
assertEq(nftDealers.ownerOf(1), userWithCash);
assertEq(
usdc.balanceOf(userWithCash),
userWithCashInitialBalance - ONE_USDC
); // userWithCash paid 1 USDC instead of price
}

Recommended Mitigation

Change the price field in the Listing struct and the _price parameter in the list and updatePrice functions from uint32 to uint256. This eliminates the silent truncation on downcasting and ensures that all fee brackets defined in _calculateFees are reachable, aligning the type with the intended price range of the contract.

struct Listing {
address seller;
- uint32 price;
+ uint2556 price;
address nft;
uint256 tokenId;
bool isActive;
}
- function list(uint256 _tokenId, uint32 _price) external onlyWhitelisted {
+ function list(uint256 _tokenId, uint256 _price) external onlyWhitelisted {
require(_price >= MIN_PRICE, "Price must be at least 1 USDC");
require(ownerOf(_tokenId) == msg.sender, "Not owner of NFT");
require(s_listings[_tokenId].isActive == false, "NFT is already listed");
// @audit-info check inutile
require(_price > 0, "Price must be greater than 0");
listingsCounter++;
activeListingsCounter++;
s_listings[_tokenId] =
Listing({seller: msg.sender, price: _price, nft: address(this), tokenId: _tokenId, isActive: true});
emit NFT_Dealers_Listed(msg.sender, listingsCounter);
}
- function updatePrice(uint256 _listingId, uint32 _newPrice) external onlySeller(_listingId) {
+ function updatePrice(uint256 _listingId, uin256 _newPrice) external onlySeller(_listingId) {
Listing memory listing = s_listings[_listingId];
uint256 oldPrice = listing.price;
if (!listing.isActive) revert ListingNotActive(_listingId);
require(_newPrice > 0, "Price must be greater than 0");
s_listings[_listingId].price = _newPrice;
emit NFT_Dealers_Price_Updated(_listingId, oldPrice, _newPrice);
}

Support

FAQs

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

Give us feedback!