NFT Dealers

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

updatePrice() bypasses the minimum price rule

Author Revealed upon completion

Description

  • Under the intended marketplace rules, listings must respect a minimum price floor of MIN_PRICE = 1e6 (1 USDC with 6 decimals). The list() function enforces this rule by requiring _price >= MIN_PRICE, which means newly created listings are not supposed to be cheaper than 1 USDC.

  • The issue is that updatePrice() does not enforce the same minimum price rule. Instead of checking _newPrice >= MIN_PRICE, it only checks _newPrice > 0. As a result, a seller can create a valid listing at 1 USDC or more, and then immediately lower it to any nonzero amount, including 1 raw unit (0.000001 USDC), which is far below the documented floor.

uint256 public constant MIN_PRICE = 1e6; // 1 USDC
function list(uint256 _tokenId, uint32 _price) external onlyWhitelisted {
require(_price >= MIN_PRICE, "Price must be at least 1 USDC"); // @> minimum enforced on creation
require(ownerOf(_tokenId) == msg.sender, "Not owner of NFT");
require(s_listings[_tokenId].isActive == false, "NFT is already listed");
require(_price > 0, "Price must be greater than 0");
...
}
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"); // @> allows prices below MIN_PRICE
s_listings[_listingId].price = _newPrice;
emit NFT_Dealers_Price_Updated(_listingId, oldPrice, _newPrice);
}

Risk

Likelihood: High

  • This occurs whenever a seller creates any valid listing and then updates the price, because the update path accepts any value greater than zero rather than the documented minimum price.

  • This occurs naturally in normal marketplace usage because price updates are an intended user action and do not require any unusual permissions or timing beyond owning the active listing.

Impact: Medium

  • The protocol’s pricing invariant is broken, allowing sellers to publish effective dust listings below the stated 1 USDC floor.

  • The fee model and user expectations become inconsistent, because sub-minimum prices can result in zero effective fees after integer division and create listings that the UI/spec likely assumes are impossible.

Proof of Concept

  • Add import {console2} from "forge-std/console2.sol"; at the top of NFTDealersTest.t.sol.

  • Copy the code below to NFTDealersTest contract.

  • Run command forge test --mt testUpdatePriceBypassesMinimumPriceRule -vv --via-ir.

function testUpdatePriceBypassesMinimumPriceRule() public revealed whitelisted {
uint256 tokenId = 1;
uint32 initialValidPrice = 1e6; // exactly MIN_PRICE = 1 USDC
uint32 invalidUpdatedPrice = 1; // 0.000001 USDC, clearly below MIN_PRICE
console2.log("MIN_PRICE:", nftDealers.MIN_PRICE());
console2.log("initialValidPrice:", uint256(initialValidPrice));
console2.log("invalidUpdatedPrice:", uint256(invalidUpdatedPrice));
console2.log("lockAmount:", nftDealers.lockAmount());
// ------------------------------------------------------------
// Phase 1: mint and create a valid listing at the minimum price
// ------------------------------------------------------------
mintAndListNFTForTesting(tokenId, initialValidPrice);
(
address sellerBeforeUpdate,
uint32 storedPriceBeforeUpdate,,
uint256 listedTokenIdBeforeUpdate,
bool isActiveBeforeUpdate
) = nftDealers.s_listings(tokenId);
console2.log("Stored seller before update:", sellerBeforeUpdate);
console2.log("Stored price before update:", uint256(storedPriceBeforeUpdate));
console2.log("Stored tokenId before update:", listedTokenIdBeforeUpdate);
console2.log("Listing active before update:", isActiveBeforeUpdate ? uint256(1) : uint256(0));
assertEq(storedPriceBeforeUpdate, initialValidPrice, "sanity: listing starts at valid MIN_PRICE");
assertTrue(isActiveBeforeUpdate, "sanity: listing should be active before update");
// ------------------------------------------------------------
// Phase 2: lower the price below MIN_PRICE using updatePrice()
// ------------------------------------------------------------
vm.prank(userWithCash);
nftDealers.updatePrice(tokenId, invalidUpdatedPrice);
(
address sellerAfterUpdate,
uint32 storedPriceAfterUpdate,,
uint256 listedTokenIdAfterUpdate,
bool isActiveAfterUpdate
) = nftDealers.s_listings(tokenId);
console2.log("Stored seller after update:", sellerAfterUpdate);
console2.log("Stored price after update:", uint256(storedPriceAfterUpdate));
console2.log("Stored tokenId after update:", listedTokenIdAfterUpdate);
console2.log("Listing active after update:", isActiveAfterUpdate ? uint256(1) : uint256(0));
// Core bug signal: an active listing now exists below MIN_PRICE
assertEq(storedPriceAfterUpdate, invalidUpdatedPrice, "BUG: updatePrice accepted a price below MIN_PRICE");
assertTrue(
uint256(storedPriceAfterUpdate) < nftDealers.MIN_PRICE(), "BUG: active listing price is below MIN_PRICE"
);
assertTrue(isActiveAfterUpdate, "listing remains active at a forbidden price");
// ------------------------------------------------------------
// Phase 3: complete a real purchase at the forbidden sub-minimum price
// ------------------------------------------------------------
vm.startPrank(userWithEvenMoreCash);
usdc.approve(address(nftDealers), invalidUpdatedPrice);
nftDealers.buy(tokenId);
vm.stopPrank();
console2.log("Buyer balance after buy:", usdc.balanceOf(userWithEvenMoreCash));
console2.log("Contract balance after buy:", usdc.balanceOf(address(nftDealers)));
console2.log("Owner of token after buy:", nftDealers.ownerOf(tokenId));
assertEq(nftDealers.ownerOf(tokenId), userWithEvenMoreCash, "sale at sub-minimum price completed successfully");
// Collect proceeds so we can observe the final settlement values
vm.prank(userWithCash);
nftDealers.collectUsdcFromSelling(tokenId);
uint256 sellerFinalBalance = usdc.balanceOf(userWithCash);
uint256 contractFinalBalance = usdc.balanceOf(address(nftDealers));
uint256 expectedFee = nftDealers.calculateFees(invalidUpdatedPrice);
uint256 expectedSellerPayout = uint256(invalidUpdatedPrice) + nftDealers.lockAmount() - expectedFee;
console2.log("expectedFee:", expectedFee);
console2.log("expectedSellerPayout:", expectedSellerPayout);
console2.log("sellerFinalBalance:", sellerFinalBalance);
console2.log("contractFinalBalance:", contractFinalBalance);
// With price = 1 raw unit, integer division makes the fee zero.
assertEq(expectedFee, 0, "sanity: dust sale price rounds fee down to zero");
assertEq(
sellerFinalBalance,
expectedSellerPayout,
"BUG: seller settled a sale at a price below the documented minimum"
);
}

Output:

[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for test/NFTDealersTest.t.sol:NFTDealersTest
[PASS] testUpdatePriceBypassesMinimumPriceRule() (gas: 479362)
Logs:
MIN_PRICE: 1000000
initialValidPrice: 1000000
invalidUpdatedPrice: 1
lockAmount: 20000000
Stored seller before update: 0x22CdC71E987473D657FCe79C9C0C0B1A62148056
Stored price before update: 1000000
Stored tokenId before update: 1
Listing active before update: 1
Stored seller after update: 0x22CdC71E987473D657FCe79C9C0C0B1A62148056
Stored price after update: 1
Stored tokenId after update: 1
Listing active after update: 1
Buyer balance after buy: 199999999999
Contract balance after buy: 20000001
Owner of token after buy: 0x533575789af8F38A73C7747E36C17C1835FDF44a
expectedFee: 0
expectedSellerPayout: 20000001
sellerFinalBalance: 20000001
contractFinalBalance: 0
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 4.28ms (2.04ms CPU time)
Ran 1 test suite in 15.51ms (4.28ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommended Mitigation

  • updatePrice() should enforce the same minimum price rule that list() already enforces.

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!