Root + Impact
Description
Normal behavior: During bidding, contracts should emit bid-related events (e.g., BidPlaced, AuctionExtended). The settlement event should be emitted only once the auction is finalized (after NFT transfer and payments), typically inside the settlement routine.
Issue: placeBid() emits AuctionSettled immediately after basic checks, before any settlement logic and even before updating the highest bid, which incorrectly signals that the auction has concluded. Off-chain consumers (indexers, bots, dashboards) that rely on AuctionSettled for final prices/owners/fees will be misled, potentially triggering early payouts, incorrect accounting, or broken UX. _executeSale() later emits AuctionSettled again, compounding confusion (duplicate/conflicting settlements).
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");
emit AuctionSettled(tokenId, msg.sender, listing.seller, msg.value);
}
Risk
Likelihood:
-
Occurs on every call to placeBid(), including early bids, buy-now attempts that might later revert, or bids that won’t win the auction.
Off-chain systems that subscribe to AuctionSettled will process false positives frequently.
Impact:
Operational/UX breakage: Double settlement signals (one fake from placeBid, one real from _executeSale) create data ambiguity and maintenance burdens for integrators.
Proof of Concept
1) User calls placeBid(tokenId) with a normal bid.
2) Contract immediately emits: AuctionSettled(tokenId, bidder, seller, msg.value) -> Off-chain services mark auction as settled at 'msg.value'.
3) Auction continues (more bids or time extension).
4) Later, _executeSale() runs and emits AuctionSettled again with the final price.
Result: off-chain data shows conflicting/duplicate settlements; early one is wrong.
pragma solidity 0.8.20;
import {Test, console} from "forge-std/Test.sol";
import {BidBeastsNFTMarket} from "../src/BidBeastsNFTMarketPlace.sol";
import {BidBeasts} from "../src/BidBeasts_NFT_ERC721.sol";
contract MisleadingSettlementEventPoC is Test {
BidBeastsNFTMarket market;
BidBeasts nft;
address OWNER = address(0xA1);
address SELLER = address(0xB2);
address BIDDER = address(0xC3);
uint256 constant TOKEN_ID = 0;
uint256 constant MIN_PRICE = 1 ether;
event AuctionSettled(uint256 tokenId, address winner, address seller, uint256 price);
function setUp() public {
vm.startPrank(OWNER);
nft = new BidBeasts();
market = new BidBeastsNFTMarket(address(nft));
vm.stopPrank();
vm.deal(SELLER, 100 ether);
vm.deal(BIDDER, 100 ether);
vm.prank(OWNER);
nft.mint(SELLER);
vm.startPrank(SELLER);
nft.approve(address(market), TOKEN_ID);
market.listNFT(TOKEN_ID, MIN_PRICE, 0);
vm.stopPrank();
}
function test_SettlementEventEmittedDuringBid_shouldNotHappen() public {
vm.prank(BIDDER);
vm.expectEmit(true, true, true, true);
emit AuctionSettled(TOKEN_ID, BIDDER, SELLER, MIN_PRICE);
market.placeBid{value: MIN_PRICE}(TOKEN_ID);
}
}
Recommended Mitigation
Notes
Keep one settlement signal (in _executeSale) as the canonical source of truth.
Consider adding explicit lifecycle events (NftListed, AuctionExtended, NftUnlisted)—you already have them; ensure semantics stay phase-accurate.
Optionally switch to safeTransferFrom in _executeSale to avoid transfers to non-receiver contracts (design choice).
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");
- // Wrong semantic: settlement event during bidding
- emit AuctionSettled(tokenId, msg.sender, listing.seller, msg.value);
// --- Buy Now Logic ---
if (listing.buyNowPrice > 0 && msg.value >= listing.buyNowPrice) {
...
_executeSale(tokenId); // emits AuctionSettled once, at the true settlement point
...
return;
}
// bidding logic (start/extend auction, compute required amount, etc.)
...
- emit BidPlaced(tokenId, msg.sender, msg.value);
+ // Emit only bid-phase signals here
+ emit BidPlaced(tokenId, msg.sender, msg.value);
}
function _executeSale(uint256 tokenId) internal {
Listing storage listing = listings[tokenId];
Bid memory bid = bids[tokenId];
listing.listed = false;
delete bids[tokenId];
- BBERC721.transferFrom(address(this), bid.bidder, tokenId);
+ // (Optional hardening) Use safeTransferFrom to prevent token lock on non-receiver contracts
+ BBERC721.transferFrom(address(this), bid.bidder, tokenId);
uint256 fee = (bid.amount * S_FEE_PERCENTAGE) / 100;
s_totalFee += fee;
uint256 sellerProceeds = bid.amount - fee;
_payout(listing.seller, sellerProceeds);
// Single authoritative settlement event here only
emit AuctionSettled(tokenId, bid.bidder, listing.seller, bid.amount);
}