NFT Dealers

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

M05. `updatePrice()` accepts any price above zero, allowing sellers to undercut the protocol's minimum price

Author Revealed upon completion

Root + Impact

Description

  • list() enforces _price >= MIN_PRICE (1 USDC), ensuring listings are created at a meaningful minimum.

  • updatePrice() only checks _newPrice > 0, so a seller can update an existing listing to a price of 1 unit (0.000001 USDC) after it has been created. The minimum price invariant can be bypassed through this path.

function list(uint256 _tokenId, uint32 _price) external onlyWhitelisted {
// @> MIN_PRICE enforced here
require(_price >= MIN_PRICE, "Price must be at least 1 USDC");
// ...
}
function updatePrice(uint256 _listingId, uint32 _newPrice) external onlySeller(_listingId) {
Listing memory listing = s_listings[_listingId];
uint256 oldPrice = listing.price;
if (!listing.isActive) revert ListingNotActive(_listingId);
// @> only checks > 0, not >= MIN_PRICE — minimum price bypass
require(_newPrice > 0, "Price must be greater than 0");
s_listings[_listingId].price = _newPrice;
emit NFT_Dealers_Price_Updated(_listingId, oldPrice, _newPrice);
}

Risk

Likelihood: High

  • Any whitelisted seller can call updatePrice() on their active listing at any time with no special conditions.

  • The check _newPrice > 0 is trivially satisfied by setting _newPrice = 1.

Impact: Medium

  • Breaking the minimum price invariant enables a front-running attack: a seller sees a buyer's approve(contract, 500e6) transaction in the mempool and front-runs it with updatePrice(listingId, 1). The buyer's subsequent buy() call completes and pays 1 unit (0.000001 USDC) for the NFT instead of the expected price.

  • Any off-chain tool or aggregator that caches listing prices will show stale data, leading to purchases at unintended prices.

Proof of Concept

A seller lists at 500 USDC (valid). They then update the price to 1 unit before the buyer's transaction executes, causing the buyer to pay essentially nothing.

function test_updatePrice_frontRunsBuyer() public {
uint32 initialPrice = 500e6;
vm.prank(seller);
nftDealers.list(1, initialPrice);
// Buyer approves for the displayed price of 500 USDC
vm.prank(buyer);
usdc.approve(address(nftDealers), initialPrice);
// Seller front-runs: drops price to 1 unit before buy() lands
vm.prank(seller);
nftDealers.updatePrice(1, 1); // no revert — MIN_PRICE not checked
// Buyer's buy() goes through at price=1 instead of 500e6
vm.prank(buyer);
nftDealers.buy(1);
// Seller received 1 unit instead of 500e6 — but now exploits H-01 to still drain contract
(, uint32 finalPrice, , , ) = nftDealers.s_listings(1);
assertEq(finalPrice, 1, "price was successfully manipulated to 1 unit");
assertEq(nftDealers.ownerOf(1), buyer, "buyer received the NFT at near-zero cost");
}

Recommended Mitigation

Apply the same MIN_PRICE check in updatePrice() as is already present in list().

function updatePrice(uint256 _listingId, uint32 _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");
+ require(_newPrice >= MIN_PRICE, "Price must be at least 1 USDC");
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!