diff --git a/.gitmodules b/.gitmodules index 508322dd..5cdd4bc2 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [submodule "solidity-sdk/lib/openzeppelin-contracts"] path = solidity-sdk/lib/openzeppelin-contracts url = https://github.com/OpenZeppelin/openzeppelin-contracts +[submodule "examples/auction-settlement/lib/forge-std"] + path = examples/auction-settlement/lib/forge-std + url = https://github.com/foundry-rs/forge-std diff --git a/examples/auction-settlement/.gitignore b/examples/auction-settlement/.gitignore new file mode 100644 index 00000000..85198aaa --- /dev/null +++ b/examples/auction-settlement/.gitignore @@ -0,0 +1,14 @@ +# Compiler files +cache/ +out/ + +# Ignores development broadcast logs +!/broadcast +/broadcast/*/31337/ +/broadcast/**/dry-run/ + +# Docs +docs/ + +# Dotenv file +.env diff --git a/examples/auction-settlement/README.md b/examples/auction-settlement/README.md new file mode 100644 index 00000000..47a3529f --- /dev/null +++ b/examples/auction-settlement/README.md @@ -0,0 +1,66 @@ +# Auction Settlement Example + +An NFT auction system that demonstrates how Pod enables fair settlement **without total ordering**. Auctioneers can sell NFT trophies for ERC20 tokens, with winners determined by the highest bid submitted before the deadline. + +## How It Works + +The auction contract achieves fair settlement despite lack of total ordering allowing for extremely fast finality and minimal overhead, providing the following guarantees: + +- Short-term censorship resistance: There is no leader or sequencer that can censor bids for a short time. +- Bid submission deadline: Only bids submitted before the deadline can win. + +Even though transactions may be processed in different orders across Pod nodes, the contract can still determine the correct winner and settle fairly. + +Because bids must be submitted in time, it is crucial that a client submits both rounds of execution to pod before the auction deadline to a supermajority of the validators. If the client fails to do so and only submits the transaction to some of the validators, the bid is considered malicious and the client may lose the money he bid for the auction without being able to refund. + +## Usage + +### Build and Test + +```bash +forge build +forge test +``` + +### Deploy + +```bash +forge script script/Auction.s.sol --rpc-url --private-key --broadcast +``` + +### Using Cast + +```bash +# Start an auction (returns auction ID) +cast send "startAuction(address,uint256,address,uint64)" \ + \ + --rpc-url --private-key + +# Submit a bid +cast send "submitBid(uint256,uint256)" \ + \ + --rpc-url --private-key + +# After deadline - winner claims trophy +cast send "claimTrophy(uint256,uint256)" \ + \ + --rpc-url --private-key + +# Losers get refunds by proving a higher bid exists +cast send "refundBid(uint256,uint256,uint256)" \ + \ + --rpc-url --private-key +``` + +### Core Functions + +- `startAuction()` - Creates auction and escrows NFT trophy +- `submitBid()` - Places bid with ERC20 payment (accepted even after deadline) +- `claimTrophy()` - Winner claims NFT after deadline using quorum proof +- `refundBid()` - Losers reclaim tokens by proving a higher bid +- `claimPayout()` - Auctioneer receives winning bid amount + + +## License + +MIT diff --git a/examples/auction-settlement/contracts/Auction.sol b/examples/auction-settlement/contracts/Auction.sol new file mode 100644 index 00000000..06a68518 --- /dev/null +++ b/examples/auction-settlement/contracts/Auction.sol @@ -0,0 +1,328 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {requireTimeBefore, requireTimeAfter, Time} from "pod-sdk/Time.sol"; +import {requireQuorum} from "pod-sdk/Quorum.sol"; + +using Time for Time.Timestamp; + +interface IERC20 { + function transferFrom( + address from, + address to, + uint256 amount + ) external returns (bool); +} + +interface IERC721 { + function transferFrom(address from, address to, uint256 tokenId) external; +} + +contract Auction { + struct Bid { + address bidder; + uint256 amount; + bool processed; + } + + struct AuctionData { + address auctioneer; + address tokenContract; + address trophyContract; + uint256 trophyTokenId; + uint256 payoutGiven; + bool prizeRefunded; + Time.Timestamp deadline; + uint256 winningBid; + } + + mapping(uint256 => AuctionData) public auctions; + mapping(uint256 => Bid) public bids; + + event AuctionStarted( + uint256 indexed auctionId, + address indexed auctioneer, + address trophyContract, + uint256 trophyTokenId, + Time.Timestamp deadline + ); + event WinningBid(uint256 indexed auctionId, uint256 indexed bidId); + event BidSubmitted( + uint256 indexed auctionId, + uint256 indexed bidId, + address indexed bidder, + uint256 amount + ); + event TrophyClaimed( + uint256 indexed auctionId, + uint256 indexed bidId, + address indexed bidder + ); + event BidRefunded( + uint256 indexed auctionId, + uint256 indexed bidId, + address indexed bidder, + uint256 amount + ); + event PayoutClaimed( + uint256 indexed auctionId, + uint256 indexed bidId, + address indexed auctioneer, + uint256 amount + ); + event TrophyRefunded( + uint256 indexed auctionId, + address indexed auctioneer, + uint256 indexed trophyTokenId + ); + + // startAuction creates a new auction and transfers the trophy to the contract + function startAuction( + address trophyContract, + uint256 trophyTokenId, + address tokenContract, + Time.Timestamp deadline + ) external returns (uint256 auctionId) { + requireTimeBefore(deadline, "Deadline must be in the future"); + + auctionId = uint256( + keccak256(abi.encodePacked(msg.sender, trophyTokenId, deadline)) + ); + + // Check auction doesn't already exist (deadline 0 means uninitialized) + require( + auctions[auctionId].deadline.eq(Time.Timestamp.wrap(0)), + "Auction already exists" + ); + + auctions[auctionId] = AuctionData({ + auctioneer: msg.sender, + tokenContract: tokenContract, + trophyContract: trophyContract, + trophyTokenId: trophyTokenId, + payoutGiven: 0, + prizeRefunded: false, + deadline: deadline, + winningBid: 0 + }); + + IERC721(trophyContract).transferFrom( + msg.sender, + address(this), + trophyTokenId + ); + + emit AuctionStarted( + auctionId, + msg.sender, + trophyContract, + trophyTokenId, + deadline + ); + } + + // submitBid used to participate in the auction + // payment for a bid is accepted regardless of timing + // but a bid can only be considered for winning the auction if the transaction is executed before the deadline + function submitBid(uint256 auctionId, uint256 amount) external { + AuctionData storage auction = auctions[auctionId]; + + // money submitted to a nonexistent auction will be lost + // this is on purpose to prevent race conditions between auction creation and bid submission + + // the way bid id is calculated does not allow for duplicate bids of same amount by same sender + // but there is no reason to allow for that. + uint256 bidId = uint256( + keccak256(abi.encodePacked(auctionId, msg.sender, amount)) + ); + require(bids[bidId].bidder == address(0), "Bid already exists"); + bids[bidId] = Bid(msg.sender, amount, false); + + // we check here if transaction was **executed** (2nd round-trip) before deadline + // each validator will decide to execute this block of code based on the time they received 2nd round + // therefore the state between validators will be inconsistent, + // but only an honest winning bid will be able to pass requireQuorum() that it is the winning bid. + // + // we cannot enforce this check with require/requireQuorum because payment must succeed regardless + if ( + Time.currentTime().lt(auction.deadline) && + amount > bids[auction.winningBid].amount + ) { + auction.winningBid = bidId; + emit WinningBid(auctionId, bidId); + } + + emit BidSubmitted(auctionId, bidId, msg.sender, amount); + + bool transferSuccess = IERC20(auction.tokenContract).transferFrom( + msg.sender, + address(this), + amount + ); + requireQuorum(transferSuccess, "Transfer failed"); + } + + // claimTrophy is used by the winner to receive the auctioned trophy + // highest bid must be honest (executed before deadline) to allow requireQuorum on it being the winning bid + function claimTrophy(uint256 auctionId, uint256 bidId) external { + AuctionData storage auction = auctions[auctionId]; + require(bids[bidId].bidder == msg.sender, "Not bidder"); // can only be called by the bidder so that claimTrophy/refundBid are ordered + requireTimeAfter(auction.deadline, "Deadline not passed yet"); + requireQuorum(auction.winningBid == bidId, "Not the winning bid"); + requireQuorum(!bids[bidId].processed, "Bid already processed"); + + bids[bidId].processed = true; + emit TrophyClaimed(auctionId, bidId, msg.sender); + + IERC721(auction.trophyContract).transferFrom( + address(this), + msg.sender, + auction.trophyTokenId + ); + } + + // refundBid is used by a losing bidder to get their money back + // honest bidder can refund using a higherBidId by forcing it to be executed, even after the deadline + function refundBid( + uint256 auctionId, + uint256 bidId, + uint256 higherBidId + ) external { + AuctionData storage auction = auctions[auctionId]; + require(bids[bidId].bidder == msg.sender, "Not bidder"); // can only be called by the bidder so that claimTrophy/refundBid are ordered + requireTimeAfter(auction.deadline, "Deadline not passed yet"); + requireQuorum(!bids[bidId].processed, "Bid already processed"); + requireQuorum(_isHigherBid(higherBidId, bidId), "Invalid higher bid"); + + uint256 refundAmount = bids[bidId].amount; + + bids[bidId].processed = true; + emit BidRefunded(auctionId, bidId, msg.sender, refundAmount); + + IERC20(auction.tokenContract).transferFrom(address(this), msg.sender, refundAmount); + } + + // claimPayout is used by the auctioneer to get money from the auction + // auctioneer might claim multiple times using increasing bid amounts + function claimPayout(uint256 auctionId, uint256 bidId) external { + AuctionData storage auction = auctions[auctionId]; + + require(auction.auctioneer == msg.sender, "Not auctioneer"); // can only be called by auctioneer so that claimPayout/refundTrophy are ordered + requireTimeAfter(auction.deadline, "Deadline not passed yet"); + requireQuorum( + bids[bidId].amount > auction.payoutGiven, + "Bid amount not greater than already given payout" + ); + requireQuorum(!auction.prizeRefunded, "Prize already refunded"); + + uint256 remainingAmount = bids[bidId].amount - auction.payoutGiven; + + auction.payoutGiven = bids[bidId].amount; + emit PayoutClaimed( + auctionId, + bidId, + auction.auctioneer, + remainingAmount + ); + + IERC20(auction.tokenContract).transferFrom( + address(this), + auction.auctioneer, + remainingAmount + ); + } + + // refundTrophy is used by the auctioneer to get the trophy back if there were no bids + // if there were malicious bids, auctioneer can choose to refund or accept malicious bids + // if there was an honest winning bid, auctioneer cannot refund trophy + function refundTrophy(uint256 auctionId) external { + AuctionData storage auction = auctions[auctionId]; + require(msg.sender == auction.auctioneer, "Not auctioneer"); // can only be called by auctioneer so that claimPayout/refundTrophy are ordered + requireTimeAfter(auction.deadline, "Deadline not passed yet"); + requireQuorum( + auction.payoutGiven == 0, + "Some money already paid out to the auctioneer" + ); + requireQuorum( + auction.winningBid == 0, + "There is a non-zero winning bid" + ); + requireQuorum(!auction.prizeRefunded, "Prize already refunded"); + + auction.prizeRefunded = true; + emit TrophyRefunded( + auctionId, + auction.auctioneer, + auction.trophyTokenId + ); + + IERC721(auction.trophyContract).transferFrom( + address(this), + auction.auctioneer, + auction.trophyTokenId + ); + } + + // first bid id has higher amount or has a larger hash + function _isHigherBid( + uint256 higherBidId, + uint256 bidId + ) internal view returns (bool) { + uint256 am1 = bids[higherBidId].amount; + uint256 am2 = bids[bidId].amount; + return (am1 > am2) || (am1 == am2 && higherBidId > bidId); + } + + // getWinningBid returns the current winning bid ID for an auction + function getWinningBid(uint256 auctionId) external view returns (uint256) { + return auctions[auctionId].winningBid; + } + + // getPayoutGiven returns how much payout has been given to the auctioneer + function getPayoutGiven(uint256 auctionId) external view returns (uint256) { + return auctions[auctionId].payoutGiven; + } + + // isPrizeRefunded returns whether the prize has been refunded to the auctioneer + function isPrizeRefunded(uint256 auctionId) external view returns (bool) { + return auctions[auctionId].prizeRefunded; + } + + // isBidProcessed returns whether a bid has been processed (claimed or refunded) + function isBidProcessed(uint256 bidId) external view returns (bool) { + return bids[bidId].processed; + } + + // getAuctioneer returns the auctioneer address for an auction + function getAuctioneer(uint256 auctionId) external view returns (address) { + return auctions[auctionId].auctioneer; + } + + // getTokenContract returns the token contract address for an auction + function getTokenContract( + uint256 auctionId + ) external view returns (address) { + return auctions[auctionId].tokenContract; + } + + // getTrophyContract returns the trophy contract address for an auction + function getTrophyContract( + uint256 auctionId + ) external view returns (address) { + return auctions[auctionId].trophyContract; + } + + // getTrophyTokenId returns the trophy token ID for an auction + function getTrophyTokenId( + uint256 auctionId + ) external view returns (uint256) { + return auctions[auctionId].trophyTokenId; + } + + // getDeadline returns the deadline for an auction + function getDeadline( + uint256 auctionId + ) external view returns (Time.Timestamp) { + return auctions[auctionId].deadline; + } +} diff --git a/examples/auction-settlement/foundry.toml b/examples/auction-settlement/foundry.toml new file mode 100644 index 00000000..d0043ca1 --- /dev/null +++ b/examples/auction-settlement/foundry.toml @@ -0,0 +1,15 @@ +[profile.default] +src = "contracts" +out = "out" +libs = ["lib"] +solc = "0.8.28" +evm_version = "prague" +bytecode_hash = "none" +cbor_metadata = false +remappings = ["pod-sdk/=../../solidity-sdk/src/"] +allow_paths = ["../../solidity-sdk"] + +[fmt] +exclude = ["lib/**"] + +# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/examples/auction-settlement/lib b/examples/auction-settlement/lib new file mode 120000 index 00000000..5ae36349 --- /dev/null +++ b/examples/auction-settlement/lib @@ -0,0 +1 @@ +../../solidity-sdk/lib \ No newline at end of file diff --git a/examples/auction-settlement/script/Auction.s.sol b/examples/auction-settlement/script/Auction.s.sol new file mode 100644 index 00000000..d9c4ed3d --- /dev/null +++ b/examples/auction-settlement/script/Auction.s.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.28; + +import {Script, console} from "forge-std/Script.sol"; +import {Auction} from "../contracts/Auction.sol"; +import {Time} from "pod-sdk/Time.sol"; + +contract AuctionScript is Script { + Auction public auction; + + function setUp() public {} + + function run() public { + vm.startBroadcast(); + + // Deploy the auction contract (no constructor parameters needed) + auction = new Auction(); + console.log("Auction contract deployed at:", address(auction)); + + // Example usage (commented out - replace with actual values when ready to use): + // + // address trophyContract = address(0x1); // Replace with actual ERC721 contract + // uint256 trophyTokenId = 1; // Replace with actual token ID + // address tokenContract = address(0x2); // Replace with actual ERC20 contract + // Time.Timestamp deadline = Time.fromSeconds(uint64(block.timestamp + 7 days)); + // + // uint256 auctionId = auction.startAuction( + // trophyContract, + // trophyTokenId, + // tokenContract, + // deadline + // ); + // + // console.log("Auction started with ID:", auctionId); + + console.log("To start an auction, call startAuction() with your NFT details"); + + vm.stopBroadcast(); + } +} diff --git a/examples/auction-settlement/test/Auction.t.sol b/examples/auction-settlement/test/Auction.t.sol new file mode 100644 index 00000000..83d87a86 --- /dev/null +++ b/examples/auction-settlement/test/Auction.t.sol @@ -0,0 +1,272 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.28; + +import {Test, console} from "forge-std/Test.sol"; +import {Vm} from "forge-std/Vm.sol"; +import {PodTest} from "pod-sdk/test/podTest.sol"; +import {Auction} from "../contracts/Auction.sol"; +import {Time} from "pod-sdk/Time.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; + +using Time for Time.Timestamp; + +// Test contracts extending OpenZeppelin implementations +contract TestERC20 is ERC20 { + constructor() ERC20("Test Token", "TEST") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +contract TestERC721 is ERC721 { + constructor() ERC721("Test NFT", "TNFT") {} + + function mint(address to, uint256 tokenId) external { + _mint(to, tokenId); + } +} + +contract AuctionTest is PodTest { + Auction public auction; + TestERC20 public token; + TestERC721 public trophy; + + address public auctioneer = address(0x1); + address public bidder1 = address(0x2); + address public bidder2 = address(0x3); + + uint256 public constant TROPHY_TOKEN_ID = 1; + uint256 public auctionId; + Time.Timestamp public deadline; + + function setUp() public { + // Set up POD test environment + podMockQuorum(); + + token = new TestERC20(); + trophy = new TestERC721(); + auction = new Auction(); // Deploy contract with no parameters + + // Set up initial state + deadline = Time.fromSeconds(uint64(block.timestamp + 1 days)); + podWarp(deadline); // Set the POD timestamp to the deadline for testing + + // Mint trophy to auctioneer + trophy.mint(auctioneer, TROPHY_TOKEN_ID); + + // Start auction as the auctioneer + vm.startPrank(auctioneer); + trophy.approve(address(auction), TROPHY_TOKEN_ID); // Approve the contract to transfer NFT + + // Warp back to current time so auction can be started (deadline must be in future) + podWarp(Time.fromSeconds(uint64(block.timestamp))); + + auctionId = auction.startAuction( + address(trophy), + TROPHY_TOKEN_ID, + address(token), + deadline + ); + vm.stopPrank(); + + // Give tokens to bidders + token.mint(bidder1, 1000 ether); + token.mint(bidder2, 1000 ether); + + // Approve auction contract to spend tokens + vm.prank(bidder1); + token.approve(address(auction), 1000 ether); + + vm.prank(bidder2); + token.approve(address(auction), 1000 ether); + } + + function test_StartAuction() public view { + // Test that auction was created correctly + assertEq(auction.getAuctioneer(auctionId), auctioneer); + assertEq(auction.getTokenContract(auctionId), address(token)); + assertEq(auction.getTrophyContract(auctionId), address(trophy)); + assertEq(auction.getTrophyTokenId(auctionId), TROPHY_TOKEN_ID); + assertEq(auction.getPayoutGiven(auctionId), 0); + assertEq(auction.isPrizeRefunded(auctionId), false); + assertTrue(auction.getDeadline(auctionId).eq(deadline)); + assertEq(auction.getWinningBid(auctionId), 0); + + // Test that NFT was transferred to contract + assertEq(trophy.ownerOf(TROPHY_TOKEN_ID), address(auction)); + } + + function test_SubmitBid() public { + uint256 bidAmount = 100 ether; + uint256 expectedBidId = _calculateBidId(auctionId, bidder1, bidAmount); + + // Expect the BidSubmitted event to be emitted + vm.expectEmit(true, true, true, true, address(auction)); + emit Auction.BidSubmitted(auctionId, expectedBidId, bidder1, bidAmount); + + vm.prank(bidder1); + auction.submitBid(auctionId, bidAmount); + + // Check that tokens were transferred + assertEq(token.balanceOf(bidder1), 900 ether); + assertEq(token.balanceOf(address(auction)), bidAmount); + + // Check that this became the winning bid + assertTrue(auction.getWinningBid(auctionId) != 0); + } + + function test_SubmitBidAfterDeadline() public { + // Move past deadline + podWarp(Time.fromSeconds(deadline.toSeconds() + 1)); + + uint256 bidAmount = 100 ether; + uint256 expectedBidId = _calculateBidId(auctionId, bidder1, bidAmount); + + // Expect the BidSubmitted event to be emitted (bid is accepted even after deadline) + vm.expectEmit(true, true, true, true, address(auction)); + emit Auction.BidSubmitted(auctionId, expectedBidId, bidder1, bidAmount); + + vm.prank(bidder1); + auction.submitBid(auctionId, bidAmount); + + // Bid should be accepted but not set as winning bid + assertEq(auction.getWinningBid(auctionId), 0); + assertEq(token.balanceOf(address(auction)), bidAmount); + } + + function test_MultipleWinningBids() public { + // First bid + vm.prank(bidder1); + auction.submitBid(auctionId, 100 ether); + + uint256 firstWinningBid = auction.getWinningBid(auctionId); + assertTrue(firstWinningBid != 0); + + // Second higher bid + vm.prank(bidder2); + auction.submitBid(auctionId, 200 ether); + + uint256 secondWinningBid = auction.getWinningBid(auctionId); + assertTrue(secondWinningBid != firstWinningBid); + assertTrue(secondWinningBid != 0); + } + + + function test_ClaimTrophy() public { + uint256 bidAmount = 100 ether; + + // Submit winning bid and capture bid ID from event + uint256 bidId = _submitBid(bidder1, auctionId, bidAmount); + + // Move past deadline + podWarp(Time.fromSeconds(deadline.toSeconds() + 1)); + + // Claim trophy as winner + vm.prank(bidder1); + auction.claimTrophy(auctionId, bidId); + + // Verify NFT was transferred to winner + assertEq(trophy.ownerOf(TROPHY_TOKEN_ID), bidder1); + + // Verify bid is marked as processed + assertTrue(auction.isBidProcessed(bidId)); + } + + function test_RefundBid() public { + uint256 lowerBidAmount = 50 ether; + uint256 higherBidAmount = 100 ether; + + // Submit two bids and capture bid IDs from events + uint256 lowerBidId = _submitBid(bidder1, auctionId, lowerBidAmount); + uint256 higherBidId = _submitBid(bidder2, auctionId, higherBidAmount); + + // Move past deadline + podWarp(Time.fromSeconds(deadline.toSeconds() + 1)); + + uint256 bidder1BalanceBefore = token.balanceOf(bidder1); + + // Refund losing bid + vm.prank(bidder1); + auction.refundBid(auctionId, lowerBidId, higherBidId); + + // Verify tokens were refunded + assertEq(token.balanceOf(bidder1), bidder1BalanceBefore + lowerBidAmount); + + // Verify bid is marked as processed + assertTrue(auction.isBidProcessed(lowerBidId)); + } + + function test_ClaimPayout() public { + uint256 bidAmount = 100 ether; + + // Submit winning bid and capture bid ID from event + uint256 bidId = _submitBid(bidder1, auctionId, bidAmount); + + // Move past deadline + podWarp(Time.fromSeconds(deadline.toSeconds() + 1)); + + uint256 auctioneerBalanceBefore = token.balanceOf(auctioneer); + + // Claim payout as auctioneer + vm.prank(auctioneer); + auction.claimPayout(auctionId, bidId); + + // Verify tokens were transferred to auctioneer + assertEq(token.balanceOf(auctioneer), auctioneerBalanceBefore + bidAmount); + + // Verify payout was recorded + assertEq(auction.getPayoutGiven(auctionId), bidAmount); + } + + function test_RefundTrophy() public { + // Create auction with no bids + uint256 newAuctionId; + uint256 newTrophyTokenId = 2; + + // Mint new trophy to auctioneer + trophy.mint(auctioneer, newTrophyTokenId); + + vm.startPrank(auctioneer); + trophy.approve(address(auction), newTrophyTokenId); + + newAuctionId = auction.startAuction( + address(trophy), + newTrophyTokenId, + address(token), + deadline + ); + vm.stopPrank(); + + // Move past deadline (no bids submitted) + podWarp(Time.fromSeconds(deadline.toSeconds() + 1)); + + // Refund trophy as auctioneer + vm.prank(auctioneer); + auction.refundTrophy(newAuctionId); + + // Verify NFT was returned to auctioneer + assertEq(trophy.ownerOf(newTrophyTokenId), auctioneer); + + // Verify prize refund was recorded + assertTrue(auction.isPrizeRefunded(newAuctionId)); + } + + // Helper functions + + // Helper function to calculate bid ID (same logic as in contract) + function _calculateBidId(uint256 _auctionId, address bidder, uint256 amount) internal pure returns (uint256) { + return uint256(keccak256(abi.encodePacked(_auctionId, bidder, amount))); + } + + // Helper function to submit bid and return the bid ID + function _submitBid(address bidder, uint256 _auctionId, uint256 amount) internal returns (uint256 bidId) { + bidId = _calculateBidId(_auctionId, bidder, amount); + + vm.prank(bidder); + auction.submitBid(_auctionId, amount); + + return bidId; + } +}