Bid Beasts

First Flight #49
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Severity: medium
Valid

Precision loss in bid increment

Root + Impact

Description

  • The market place requires each bid to be at least 5% higher than the preceding bid in order to ensure significant price increases and prevent spam bidding with very small increments

  • Calculation of the bid increment performs division before multiplication which causes integer truncation which allows new bids to be smaller than the intended 105% minimum

function placeBid(uint256 tokenId) external payable isListed(tokenId) {
Listing storage listing = listings[tokenId];
address previousBidder = bids[tokenId].bidder;
uint256 previousBidAmount = bids[tokenId].amount;
require(listing.seller != msg.sender, "Seller cannot bid");
require(listing.auctionEnd == 0 || block.timestamp < listing.auctionEnd, "Auction ended");
// --- Buy Now Logic ---
if (listing.buyNowPrice > 0 && msg.value >= listing.buyNowPrice) {
uint256 salePrice = listing.buyNowPrice;
uint256 overpay = msg.value - salePrice;
bids[tokenId] = Bid(msg.sender, salePrice);
listing.listed = false;
if (previousBidder != address(0)) {
_payout(previousBidder, previousBidAmount);
}
_executeSale(tokenId);
if (overpay > 0) {
_payout(msg.sender, overpay);
}
return;
}
require(msg.sender != previousBidder, "Already highest bidder");
emit AuctionSettled(tokenId, msg.sender, listing.seller, msg.value);
// --- Regular Bidding Logic ---
uint256 requiredAmount;
if (previousBidAmount == 0) {
requiredAmount = listing.minPrice;
require(msg.value > requiredAmount, "First bid must be > min price");
listing.auctionEnd = block.timestamp + S_AUCTION_EXTENSION_DURATION;
emit AuctionExtended(tokenId, listing.auctionEnd);
} else {
requiredAmount = (previousBidAmount / 100) * (100 + S_MIN_BID_INCREMENT_PERCENTAGE);
// @> Division by 100 truncates decimals beofre multiplication
// @> For previousBid = 199 wei: (199/100) * 105 = 1 * 105 = 105 wei
// @> Should be: 199 * 1.05 = 208.95 wei
require(msg.value >= requiredAmount, "Bid not high enough");
uint256 timeLeft = 0;
if (listing.auctionEnd > block.timestamp) {
timeLeft = listing.auctionEnd - block.timestamp;
}
if (timeLeft < S_AUCTION_EXTENSION_DURATION) {
listing.auctionEnd = listing.auctionEnd + S_AUCTION_EXTENSION_DURATION;
emit AuctionExtended(tokenId, listing.auctionEnd);
}
}
bids[tokenId] = Bid(msg.sender, msg.value);
if (previousBidder != address(0)) {
_payout(previousBidder, previousBidAmount);
}
emit BidPlaced(tokenId, msg.sender, msg.value);
}

Risk

Likelihood:

  • Every bid calculation after the first bid uses this flawed formula

  • The truncation occurs for all bid amounts not divisible by 100

Impact:

  • Bidders can underbid by up to 99 wei per 100 wei of the previous bid amount

  • Cumulative advantage over multiple bid increments

Proof of Concept

function testBidIncrementPrecisionLoss() public {
// Setup auction with seller and initial bidder
address seller = address(0x1);
address bidder1 = address(0x2);
address bidder2 = address(0x3);
vm.prank(owner);
nft.mint(seller, 1);
vm.startPrank(seller);
nft.approve(address(marketplace), 1);
marketplace.listNFT(1, 0.01 ether, 0);
vm.stopPrank();
// First bid
uint256 firstBid = 1.99 ether;
vm.deal(bidder1, firstBid);
vm.prank(bidder1);
marketplace.placeBid{value: firstBid}(1);
// Calculate what 105% should actually be
uint256 actualRequired = (firstBid * 105) / 100; // 2.0895 ETH
// Calculate what the flawed formula produces
uint256 flawedRequired = (firstBid / 100) * 105; // 2.079 ETH
// Bidder2 can bid 0.0105 ETH less than intended
uint256 underbid = actualRequired - flawedRequired;
assertEq(underbid, 0.0105 ether); // Saves ~$35 at $3500/ETH
// Bidder2 successfully places underbid
vm.deal(bidder2, flawedRequired);
vm.prank(bidder2);
marketplace.placeBid{value: flawedRequired}(1);
assert(flawedRequired < actualRequired);
}

Recommended Mitigation

+ // Add safe math for ceiling division
+ function ceilDiv(uint256 a, uint256 b) private pure returns (uint256) {
+ return (a + b - 1) / b;
+ }
function placeBid(uint256 tokenId) external payable isListed(tokenId) {
// ... same code until the calculation ...
} else {
- requiredAmount = (previousBidAmount / 100) * (100 + S_MIN_BID_INCREMENT_PERCENTAGE);
+ requiredAmount = ceilDiv(previousBidAmount * (100 + S_MIN_BID_INCREMENT_PERCENTAGE), 100);
require(msg.value >= requiredAmount, "Bid not high enough");
// ... rest of function ...
}
Updates

Lead Judging Commences

cryptoghost Lead Judge 2 months ago
Submission Judgement Published
Validated
Assigned finding tags:

BidBeasts Marketplace: Integer Division Precision Loss

Integer division in requiredAmount truncates fractions, allowing bids slightly lower than intended.

Support

FAQs

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

Give us feedback!