NFT Dealers

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

`buy()` has no `maxPrice` parameter, allowing seller to front-run with `updatePrice` and overcharge the buyer

Author Revealed upon completion

Description

A buyer calls buy(listingId) to purchase an NFT at the listed price. The function reads listing.price from storage at execution time and transfers that amount from the buyer.

buy() accepts no maxPrice parameter. The seller can call updatePrice between the buyer's transaction submission and execution, inflating the price up to type(uint32).max (~4294 USDC). The buyer's transaction executes at the new price with no way to reject it. There is also no deadline parameter, so stale transactions in the mempool remain valid indefinitely.

function buy(uint256 _listingId) external payable {
Listing memory listing = s_listings[_listingId];
if (!listing.isActive) revert ListingNotActive(_listingId);
require(listing.seller != msg.sender, "Seller cannot buy their own NFT");
activeListingsCounter--;
@> bool success = usdc.transferFrom(msg.sender, address(this), listing.price);
// listing.price is read from storage — no maxPrice check, no deadline
require(success, "USDC transfer failed");
_safeTransfer(listing.seller, msg.sender, listing.tokenId, "");
s_listings[_listingId].isActive = false;
}
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");
// No MIN_PRICE check — price can be set to any uint32 value
@> s_listings[_listingId].price = _newPrice;
}

Risk

Likelihood:

  • Every time a buyer submits a buy() transaction, the seller can front-run it with updatePrice to inflate the price. The buyer has no way to specify a maximum acceptable price.

  • Transactions without a deadline remain valid in the mempool indefinitely, giving the seller unlimited time to observe and front-run.

Impact:

  • The buyer pays up to ~4294 USDC (type(uint32).max) instead of the originally listed price. On a 10 USDC listing, this is a 429x overpayment.

  • Damage is bounded by the uint32 price type (~4294 USDC max) and the buyer's USDC approval. Buyers who approve exact amounts are protected by the ERC20 allowance, but buyers with large or max approvals are fully exposed.

Proof of Concept

A seller lists an NFT at 10 USDC. The buyer sees the listing, approves 10,000 USDC (a generous amount for multiple future purchases), and submits buy(). The seller front-runs with updatePrice(tokenId, type(uint32).max), inflating the price to ~4294 USDC. The buyer's buy() executes at the inflated price. The buyer pays 4294 USDC instead of 10 — a 429x overpayment — with no revert or warning.

function test_poc_buyFrontrunNoSlippage() public {
uint256 tokenId = 1;
// Seller mints and lists at 10 USDC
vm.startPrank(seller);
usdc.approve(address(nftDealers), LOCK_AMOUNT);
nftDealers.mintNft();
nftDealers.list(tokenId, ORIGINAL_PRICE);
vm.stopPrank();
// Buyer approves a large amount (sees 10 USDC listing, approves generously)
vm.prank(buyer);
usdc.approve(address(nftDealers), 10_000e6);
// Seller front-runs buy() with updatePrice to max uint32
vm.prank(seller);
nftDealers.updatePrice(tokenId, INFLATED_PRICE);
// Buyer's buy() executes at the inflated price — no maxPrice check
vm.prank(buyer);
nftDealers.buy(tokenId);
// Buyer paid ~4294 USDC instead of 10 USDC
uint256 buyerPaid = 10_000e6 - usdc.balanceOf(buyer);
assertEq(buyerPaid, INFLATED_PRICE);
assertGt(buyerPaid, uint256(ORIGINAL_PRICE) * 400); // 429x overpayment
}

Recommended Mitigation

Add maxPrice and deadline parameters to buy(). The buyer specifies the maximum price they accept and a timestamp after which the transaction expires. This prevents both price inflation front-runs and stale mempool transactions.

- function buy(uint256 _listingId) external payable {
+ function buy(uint256 _listingId, uint256 maxPrice, uint256 deadline) external payable {
Listing memory listing = s_listings[_listingId];
if (!listing.isActive) revert ListingNotActive(_listingId);
require(listing.seller != msg.sender, "Seller cannot buy their own NFT");
+ require(listing.price <= maxPrice, "Price exceeds maximum");
+ require(block.timestamp <= deadline, "Transaction expired");
activeListingsCounter--;
bool success = usdc.transferFrom(msg.sender, address(this), listing.price);

Support

FAQs

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

Give us feedback!