diff --git a/foundry.toml b/foundry.toml index 25b918f..a8831a5 100644 --- a/foundry.toml +++ b/foundry.toml @@ -3,4 +3,8 @@ src = "src" out = "out" libs = ["lib"] +[fmt] +sort_imports = true +wrap_comments = true + # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/src/BaseSealedBidAuction.sol b/src/BaseSealedBidAuction.sol index 633661b..8961c6d 100644 --- a/src/BaseSealedBidAuction.sol +++ b/src/BaseSealedBidAuction.sol @@ -7,18 +7,23 @@ import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; * @title BaseSealedBidAuction * @notice A base contract for sealed-bid auctions with a commit-reveal scheme and over-collateralization. * Each user has exactly one active bid, which can be overwritten (topped up) before `commitDeadline`. - * This contract only handles commit-reveal and overcollateralization logic, and can be used with different auction types. - * It is recommended to use one of the child contracts(`FirstPriceSealedBidAuction` or `SecondPriceSealedBidAuction`) instead - * of using this contract directly, as they implement the logic for determining the winner, final price, and update the contract state accordingly. + * This contract only handles commit-reveal and overcollateralization logic, and can be used with different + * auction types. + * It is recommended to use one of the child contracts(`FirstPriceSealedBidAuction` or + * `SecondPriceSealedBidAuction`) instead + * of using this contract directly, as they implement the logic for determining the winner, final price, and + * update the contract state accordingly. * @dev * Privacy is achieved by hashing the commit and allowing overcollaterilzation. * The contract ensure bidders commit(are not able to back out of their bid) by taking custody fo the funds. - * The contract ensures that bidders always reveal their bids, otherwise their funds are stuck(this can be customized by overriding `_checkWithdrawal`) + * The contract ensures that bidders always reveal their bids, otherwise their funds are stuck(this can be customized + * by overriding `_checkWithdrawal`) * - Bidder commits by providing a `commitHash` plus some ETH collateral >= intended bid. * - If they want to raise or change their hidden bid, they call `commitBid` again with a new hash, sending more ETH. * - During reveal, user reveals `(salt, amount)`. If `collateral < amount`, reveal fails. * - Child contracts handle final pricing logic (first-price or second-price). - * - This design is heavily inspired by [OverCollateralizedAuction from a16z](https://github.com/a16z/auction-zoo/blob/main/src/sealed-bid/over-collateralized-auction/OverCollateralizedAuction.sol) + * - This design is heavily inspired by [OverCollateralizedAuction from + * a16z](https://github.com/a16z/auction-zoo/blob/main/src/sealed-bid/over-collateralized-auction/OverCollateralizedAuction.sol) */ abstract contract BaseSealedBidAuction is ReentrancyGuard { /// @notice The address of the seller or beneficiary @@ -141,12 +146,14 @@ abstract contract BaseSealedBidAuction is ReentrancyGuard { /** * @notice Commit a sealed bid or update an existing commitment with more collateral. - * @dev It is strongly recommended that salt is a random value, and the bid is overcollateralized to avoid leaking information about the bid value. + * @dev It is strongly recommended that salt is a random value, and the bid is overcollateralized to avoid leaking + * information about the bid value. * - Overwrites the old commitHash with the new one (if any). * - Accumulates the new ETH into user’s collateral. * @param commitHash The hash commitment to the bid, computed as * `bytes20(keccak256(abi.encode(salt, bidValue)))` - * It is strongly recommended that salt is generated offchain, and is a random value, to avoid other actors from guessing the bid value. + * It is strongly recommended that salt is generated offchain, and is a random value, to avoid + * other actors from guessing the bid value. */ function commitBid(bytes20 commitHash) external payable { if (block.timestamp < startTime || block.timestamp > commitDeadline) revert NotInCommitPhase(); @@ -170,8 +177,10 @@ abstract contract BaseSealedBidAuction is ReentrancyGuard { /** * @notice Reveal the actual bid. - * @dev This function only validates the amount and salt are correct, and updates the amount of unrevealed bids left. - * The logic for determining if the bid is the best(e.g. highest bid for a first-price auction), update the records and handle refunds is handled in the child contract + * @dev This function only validates the amount and salt are correct, and updates the amount of unrevealed bids + * left. + * The logic for determining if the bid is the best(e.g. highest bid for a first-price auction), update the + * records and handle refunds is handled in the child contract * by implementing the `_handleRevealedBid` function. * @param salt Random salt used in commit * @param bidAmount The actual bid amount user is paying @@ -272,7 +281,8 @@ abstract contract BaseSealedBidAuction is ReentrancyGuard { /** * @dev Sends funds to the seller after the auction has been finalized. - * Override to implement custom logic if necessary (e.g. sending the funds to a different address or burning them) + * Override to implement custom logic if necessary (e.g. sending the funds to a different address or burning + * them) * @param amount The amount of proceeds to withdraw. */ function _withdrawSellerProceeds(uint96 amount) internal virtual { @@ -282,11 +292,14 @@ abstract contract BaseSealedBidAuction is ReentrancyGuard { /// @dev Checks if a withdrawal can be performed for `bidder`. /// - It requires that the bidder revealed their bid on time and locks the funds in the contract otherwise. - /// This is done to incentivize bidders to always reveal, instead of whitholding if they realize they overbid. - /// This logic can be customized by overriding this function, to allow for example locked funds to be withdrawn to the seller. + /// This is done to incentivize bidders to always reveal, instead of whitholding if they realize they + /// overbid. + /// This logic can be customized by overriding this function, to allow for example locked funds to be withdrawn + /// to the seller. /// Or to allow late reveals for bids that were lower than the winner's bid. /// Or to apply a late reveal penalty, but still allow the bidder to withdraw their funds. - /// WARNING: Be careful when overrding, as it can create incentives where bidders don't reveal if they realize they overbid. + /// WARNING: Be careful when overrding, as it can create incentives where bidders don't reveal if they realize + /// they overbid. /// @param bidder The address of the bidder to check /// @return amount The amount that can be withdrawn function _checkWithdrawal(address bidder) internal view virtual returns (uint96) { diff --git a/src/DutchAuction.sol b/src/DutchAuction.sol index 83e594a..d234d63 100644 --- a/src/DutchAuction.sol +++ b/src/DutchAuction.sol @@ -7,10 +7,21 @@ import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; * @title DutchAuction * @notice A Dutch auction selling multiple identical items. * @dev + * By default the price starts high and decreases over time with a linear curve. But this can be changed by overriding + * `currentPrice()` to implement any custom price curve, including a reverse dutch auction(where price starts low and + * increases over time). * - The price decreases from `startPrice` to `floorPrice` over `duration`. * - Buyers can purchase at the current price until inventory = 0 or time runs out. - * - Once time runs out or inventory hits zero, the auction is considered ended. + * - Once time runs out or inventory hits zero, the auction is considered finalized. * - If inventory remains after time ends, the seller can reclaim them via `withdrawUnsoldAssets()`. + * + * To use this contract, you must: + * 1. Provide an implementation of `_transferAssetToBuyer(address buyer, uint256 quantity)` that transfers the + * auctioned assets (e.g. NFTs) to the buyer. + * 2. Provide an implementation of `_withdrawUnsoldAssets(address seller, uint256 quantity)` that transfers the + * unsold assets back to the seller(if not all assets are sold). + * 3. Optionally override `_beforeBuy` or `_afterBuy` to implement custom bidding logic such as + * whitelisting or additional checks. */ abstract contract DutchAuction is ReentrancyGuard { /// @dev The address of the seller @@ -85,11 +96,6 @@ abstract contract DutchAuction is ReentrancyGuard { /// @dev Thrown when trying to withdraw unsold assets when all items were sold error NoUnsoldAssetsToWithdraw(); - /// @dev Thrown when floor price is set higher than start price - /// @param floorPrice The specified floor price - /// @param startPrice The specified start price - error FloorPriceExceedsStartPrice(uint256 floorPrice, uint256 startPrice); - /// @dev Thrown when auction duration is set to zero error InvalidDuration(); @@ -132,7 +138,6 @@ abstract contract DutchAuction is ReentrancyGuard { uint256 _duration, uint256 _inventory ) { - if (_floorPrice > _startPrice) revert FloorPriceExceedsStartPrice(_floorPrice, _startPrice); if (_duration == 0) revert InvalidDuration(); if (_startTime < block.timestamp) revert StartTimeInPast(_startTime, block.timestamp); if (_inventory == 0) revert InvalidInventory(); @@ -187,7 +192,8 @@ abstract contract DutchAuction is ReentrancyGuard { * @notice Send all funds in the contract to the seller. * @dev By default, this will send all funds to the seller. * It is safe to send all funds, since items are purchased immediately, so no bids are left outstanding. - * Override to implement custom logic if necessary (e.g. sending the funds to a different address or burning them) + * Override to implement custom logic if necessary (e.g. sending the funds to a different address or burning + * them) * When overriding, make sure to add necessary access control. */ function withdrawSellerProceeds() external virtual { @@ -227,7 +233,8 @@ abstract contract DutchAuction is ReentrancyGuard { /** * @notice Gets the current price per item at the current timestamp. * @dev By default, the price is a linear decrease from `startPrice` to `floorPrice` over `duration`. - * Override to implement custom curve, like exponential decay or even reverse dutch auction(where price starts low and increases over time) + * Override to implement custom curve, like exponential decay or even reverse dutch auction(where price starts + * low and increases over time) * @return The current price per item. */ function currentPrice() public view virtual returns (uint256) { diff --git a/src/EnglishAuction.sol b/src/EnglishAuction.sol index 7d831fc..3da97ea 100644 --- a/src/EnglishAuction.sol +++ b/src/EnglishAuction.sol @@ -13,16 +13,21 @@ pragma solidity ^0.8.20; * @dev * This contract is designed to be inherited and extended. * - Optional anti-sniping mechanism: If a bid arrives close to the end, the auction is extended. - * If you don't want to extend the auction in the case of a last minute bid, set the `extensionThreshold` or `extensionPeriod` to 0. + * If you don't want to extend the auction in the case of a last minute bid, set the `extensionThreshold` or + * `extensionPeriod` to 0. * * To use this contract, you must: * 1. Provide an implementation of `_transferAssetToWinner(address winner)` that transfers the * auctioned asset (e.g., an NFT) to the auction winner. - * 2. Optionally override `_beforeBid` or `_afterBid` to implement custom bidding logic such as + * 2. Provide an implementation of `_transferAssetToSeller()` that transfers the auctioned asset (e.g., an NFT) to the + * seller in case there's no winner of the auction. + * 3. Optionally override `_beforeBid` or `_afterBid` to implement custom bidding logic such as * whitelisting or additional checks. - * 3. Optionally override `_validateBidIncrement` if you want to require a certain increment over the previous highest bid. + * 4. Optionally override `_validateBidIncrement` if you want to require a certain increment over the previous highest + * bid. * - * If no valid bids are placed above the reserve price by the time the auction ends, anyone can simply finalize the auction and the asset will be returned to the seller. + * If no valid bids are placed above the reserve price by the time the auction ends, anyone can simply finalize the + * auction and the asset will be returned to the seller. */ abstract contract EnglishAuction { /// @dev The address of the item’s seller @@ -208,8 +213,10 @@ abstract contract EnglishAuction { /** * @notice Sends proceeds to the seller after the auction has been finalized. - * @dev Since `sellerProceeds` is only incremented when the auction is finalized, there's no need to check the status of the auction here. - * Override to implement custom logic if necessary (e.g. sending the funds to a different address or burning them) + * @dev Since `sellerProceeds` is only incremented when the auction is finalized, there's no need to check the + * status of the auction here. + * Override to implement custom logic if necessary (e.g. sending the funds to a different address or burning + * them) * When overriding, make sure to reset the sellerProceeds to 0 and add necessary access control. */ function withdrawSellerProceeds() external virtual { @@ -222,7 +229,8 @@ abstract contract EnglishAuction { } /** - * @notice Finalizes the auction after it ends, transfering the asset to the winner and allowing the seller to withdraw the highest bid. + * @notice Finalizes the auction after it ends, transfering the asset to the winner and allowing the seller to + * withdraw the highest bid. * @dev Anyone can call this after the auction has ended. * If no valid bids above the reserve were placed, no transfer occurs and sellerProceeds remains zero. * You need to override `_transferAssetToWinner` to implement the asset transfer logic. diff --git a/src/FirstPriceSealedBidAuction.sol b/src/FirstPriceSealedBidAuction.sol index e0ee5c5..41206c1 100644 --- a/src/FirstPriceSealedBidAuction.sol +++ b/src/FirstPriceSealedBidAuction.sol @@ -8,7 +8,8 @@ import "./BaseSealedBidAuction.sol"; * @notice An abstract contract for a first-price sealed-bid auction. * In this format, the highest bidder pays their own bid. * @dev - * - Child contracts must still override `_transferAssetToWinner()` and `_returnAssetToSeller()` for handling the transfer of the specific asset. + * - Child contracts must still override `_transferAssetToWinner()` and `_returnAssetToSeller()` for handling the + * transfer of the specific asset. * - Bids below the reserve price do not produce a winner. */ abstract contract FirstPriceSealedBidAuction is BaseSealedBidAuction { diff --git a/src/SecondPriceSealedBidAuction.sol b/src/SecondPriceSealedBidAuction.sol index ff97d6b..a7f4afd 100644 --- a/src/SecondPriceSealedBidAuction.sol +++ b/src/SecondPriceSealedBidAuction.sol @@ -6,9 +6,11 @@ import "./BaseSealedBidAuction.sol"; /** * @title SecondPriceSealedBidAuction * @notice An abstract contract for a second-price sealed-bid auction(Vickrey Auction). - * In this format, the highest bidder pays the second-highest bid(or the reserve price if there are no two bids above the reserve price). + * In this format, the highest bidder pays the second-highest bid(or the reserve price if there are no two bids + * above the reserve price). * @dev - * - Child contracts must still override `_transferAssetToWinner()` and `_returnAssetToSeller()` for handling the transfer of the specific asset. + * - Child contracts must still override `_transferAssetToWinner()` and `_returnAssetToSeller()` for handling the + * transfer of the specific asset. * - Bids below the reserve price do not produce a winner. */ abstract contract SecondPriceSealedBidAuction is BaseSealedBidAuction { @@ -44,7 +46,8 @@ abstract contract SecondPriceSealedBidAuction is BaseSealedBidAuction { */ function _handleRevealedBid(address bidder, uint96 amount) internal virtual override { uint96 currentHighestBid = highestBid; - // If the bid is the new highest bid, update highestBid and currentWinner, but also move the old highest bid to secondHighestBid + // If the bid is the new highest bid, update highestBid and currentWinner, but also move the old highest bid to + // secondHighestBid if (amount > currentHighestBid) { highestBid = amount; currentWinner = bidder; diff --git a/src/examples/NftDutchAuction.sol b/src/examples/NftDutchAuction.sol index 6fb6c88..63ffead 100644 --- a/src/examples/NftDutchAuction.sol +++ b/src/examples/NftDutchAuction.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; -import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; -import "../DutchAuction.sol"; // Import the provided DutchAuction abstract contract +import "../DutchAuction.sol"; +import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; // Import the provided DutchAuction abstract contract /** * @title NftDutchAuction diff --git a/test/DutchAuction.t.sol b/test/DutchAuction.t.sol new file mode 100644 index 0000000..5c9b959 --- /dev/null +++ b/test/DutchAuction.t.sol @@ -0,0 +1,328 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {DutchAuction} from "../src/DutchAuction.sol"; +import "forge-std/Test.sol"; + +contract MockDutchAuction is DutchAuction { + mapping(address => uint256) public buyerItemCount; + + bool public unsoldWithdrawn; + + constructor( + address _seller, + uint256 _startPrice, + uint256 _floorPrice, + uint256 _startTime, + uint256 _duration, + uint256 _inventory + ) DutchAuction(_seller, _startPrice, _floorPrice, _startTime, _duration, _inventory) { + // No custom logic needed for now + } + + // Here you would normally transfer the actual assets to the winner(e.g. the NFT) + // For testing purposes, we just set the winnerAssetRecipient to the winner + function _transferAssetToBuyer(address buyer_, uint256 quantity) internal override { + buyerItemCount[buyer_] += quantity; + } + + // Here you would normally transfer the unsold assets to the seller(e.g. the NFT) if there was no winner + // For testing purposes, we just set the a boolean flag + function _withdrawUnsoldAssets(address, /* seller_ */ uint256 /* quantity */ ) internal override { + unsoldWithdrawn = true; + } +} + +contract DutchAuctionTest is Test { + // Test addresses + address seller = address(0xA1); + address buyer1 = address(0xB1); + address buyer2 = address(0xB2); + address randomUser = address(0xC1); + + // Auction parameters + uint256 startPrice = 1 ether; + uint256 floorPrice = 0.1 ether; + uint256 startTime = 1000; // We'll warp to 1000 in setUp + uint256 duration = 1 days; + uint256 inventory = 50; // 50 identical items + + MockDutchAuction auction; + + function setUp() external { + // Warp to a known start time + vm.warp(startTime); + + // Deploy the mock Dutch Auction + auction = new MockDutchAuction(seller, startPrice, floorPrice, startTime, duration, inventory); + + // Give test addresses some ETH + vm.deal(buyer1, 10 ether); + vm.deal(buyer2, 10 ether); + vm.deal(randomUser, 1 ether); + } + + // ----------------------------------- + // Initialization & Constructor Tests + // ----------------------------------- + + function test_constructorInitializesStateCorrectly() external { + assertEq(auction.getSeller(), seller, "Seller should match constructor arg"); + assertEq(auction.getStartTime(), startTime, "startTime mismatch"); + assertEq(auction.getStartPrice(), startPrice, "startPrice mismatch"); + assertEq(auction.getFloorPrice(), floorPrice, "floorPrice mismatch"); + assertEq(auction.getInventory(), inventory, "inventory mismatch"); + assertFalse(auction.isFinished(), "Auction should not be finished initially"); + } + + function test_constructorEmitsAuctionCreatedEvent() external { + vm.expectEmit(); + emit DutchAuction.AuctionCreated(seller, startPrice, floorPrice, startTime, duration, inventory); + new MockDutchAuction(seller, startPrice, floorPrice, startTime, duration, inventory); + } + + function test_constructor_RevertWhen_ZeroDuration() external { + vm.expectRevert(DutchAuction.InvalidDuration.selector); + new MockDutchAuction( + seller, + startPrice, + floorPrice, + startTime + 10, + 0, // zero duration + 10 + ); + } + + function test_constructor_RevertWhen_StartTimeInPast() external { + // block.timestamp = 1000 in setUp, so _startTime < block.timestamp fails + vm.expectRevert( + abi.encodeWithSelector( + DutchAuction.StartTimeInPast.selector, + 900, // startTime + 1000 // blockTimestamp + ) + ); + new MockDutchAuction(seller, startPrice, floorPrice, 900, 1 days, 10); + } + + function test_constructor_RevertWhen_ZeroInventory() external { + vm.expectRevert(DutchAuction.InvalidInventory.selector); + new MockDutchAuction( + seller, + startPrice, + floorPrice, + startTime + 10, + 1 days, + 0 // zero inventory + ); + } + + // // ----------------------------------- + // // Price Function Tests + // // ----------------------------------- + + function test_currentPriceDecreasesOverTime() external { + // Initially (block.timestamp == startTime), price = startPrice + uint256 p0 = auction.currentPrice(); + assertEq(p0, startPrice, "price at startTime should be startPrice"); + + // Warp half the duration => price should be halfway between startPrice and floorPrice + vm.warp(startTime + (duration / 2)); + uint256 pHalf = auction.currentPrice(); + uint256 expectedHalf = floorPrice + ((startPrice - floorPrice) / 2); + assertEq(pHalf, expectedHalf, "price at half-time mismatch"); + + // Warp to end => price = floorPrice + vm.warp(startTime + duration + 1); + uint256 pEnd = auction.currentPrice(); + assertEq(pEnd, floorPrice, "price after endTime should be floorPrice"); + } + + // // ----------------------------------- + // // Buying Tests + // // ----------------------------------- + + function test_buyPurchasesItems() external { + // buyer1 buys 5 items at the start price + uint256 quantity = 5; + uint256 costAtStart = quantity * startPrice; // 5 * 1 ETH = 5 ETH + + vm.startPrank(buyer1); + vm.expectEmit(); + emit DutchAuction.Purchased(buyer1, quantity, costAtStart); + auction.buy{value: costAtStart}(quantity); + + // Check inventory + uint256 invAfter = auction.getInventory(); + assertEq(invAfter, inventory - quantity, "inventory not decreased by quantity"); + + // buyer1 should have 5 items + assertEq(auction.buyerItemCount(buyer1), 5, "buyer1 item count mismatch"); + } + + function test_buyRefundsExcess() external { + uint256 quantity = 5; + uint256 costAtStart = quantity * startPrice; + + uint256 userBalanceBefore = buyer1.balance; + uint256 contractBalanceBefore = address(auction).balance; + + vm.startPrank(buyer1); + // Send extra 1 ether and then check that the refund works + auction.buy{value: costAtStart + 1 ether}(quantity); + + uint256 userBalanceAfter = buyer1.balance; + uint256 contractBalanceAfter = address(auction).balance; + + assertEq(userBalanceAfter, userBalanceBefore - costAtStart, "user balance not decreased correctly"); + assertEq(contractBalanceAfter, contractBalanceBefore + costAtStart, "contract balance not increased correctly"); + } + + function test_buyAtLastSecondSucceeds() external { + vm.warp(startTime + duration); + vm.startPrank(buyer1); + + uint256 price = auction.currentPrice(); + vm.expectEmit(); + emit DutchAuction.Purchased(buyer1, 1, price); + auction.buy{value: price}(1); + } + + function test_buy_RevertWhen_AuctionNotStarted() external { + // Deploy a new auction that starts in the future + MockDutchAuction futureAuction = new MockDutchAuction( + seller, + startPrice, + floorPrice, + startTime + 1000, // starts 1000 sec in the future + duration, + inventory + ); + + vm.startPrank(buyer1); + vm.expectRevert(DutchAuction.AuctionNotStarted.selector); + futureAuction.buy{value: 1 ether}(1); + } + + function test_buy_RevertWhen_AuctionEnded() external { + // warp after end + vm.warp(startTime + duration + 1); + + vm.startPrank(buyer1); + vm.expectRevert(DutchAuction.AuctionEnded.selector); + auction.buy{value: 1 ether}(1); + } + + function test_buy_RevertWhen_InvalidQuantity() external { + // Zero quantity + vm.prank(buyer1); + vm.expectRevert(abi.encodeWithSelector(DutchAuction.InvalidQuantity.selector, 0, inventory)); + auction.buy{value: 1 ether}(0); + + // Exceed inventory + vm.prank(buyer2); + vm.expectRevert(abi.encodeWithSelector(DutchAuction.InvalidQuantity.selector, inventory + 1, inventory)); + auction.buy{value: 10 ether}(inventory + 1); + } + + function test_buy_RevertWhen_InsufficientPayment() external { + vm.prank(buyer1); + vm.expectRevert(abi.encodeWithSelector(DutchAuction.InsufficientAmount.selector, 1.5 ether, 2 ether)); + auction.buy{value: 1.5 ether}(2); + } + + // // ----------------------------------- + // // Seller Proceeds Tests + // // ----------------------------------- + + function test_withdrawSellerProceeds() external { + // buyer1 buys 2 items at start price + uint256 startPrice = auction.currentPrice(); + vm.prank(buyer1); + auction.buy{value: startPrice * 2}(2); + + // buyer2 buys one item after half duration + vm.warp(startTime + (duration / 2)); + vm.prank(buyer2); + uint256 halfDurationPrice = auction.currentPrice(); + auction.buy{value: halfDurationPrice}(1); + + // The contract now has startPrice * 2 + halfDurationPrice ETH. Let seller withdraw + uint256 sellerBalBefore = seller.balance; + vm.expectEmit(); + emit DutchAuction.FundsWithdrawn(seller, startPrice * 2 + halfDurationPrice); + auction.withdrawSellerProceeds(); + uint256 sellerBalAfter = seller.balance; + assertEq( + sellerBalAfter, + sellerBalBefore + startPrice * 2 + halfDurationPrice, + "seller should get the funds in the contract" + ); + } + + // // ----------------------------------- + // // Auction End & Unsold Withdrawal + // // ----------------------------------- + + function test_isFinishedByTime() external { + // initially not finished + assertFalse(auction.isFinished(), "should not be finished yet"); + + // warp after end + vm.warp(startTime + duration + 1); + assertTrue(auction.isFinished(), "should be finished after time expires"); + } + + function test_isFinishedByInventory() external { + // buyer1 purchases entire inventory (50) at once + uint256 price = auction.currentPrice(); + vm.deal(buyer1, price * inventory); + vm.prank(buyer1); + auction.buy{value: price * inventory}(inventory); + // now inventory=0 => isFinished should be true + assertTrue(auction.isFinished(), "auction should be finished if inventory=0"); + } + + function test_withdrawUnsoldAssets() external { + // Let half of inventory (25) be sold + uint256 price = auction.currentPrice(); + vm.deal(buyer1, price * 25); + vm.prank(buyer1); + auction.buy{value: price * 25}(25); // Overpay to be safe + + // warp to end => time is up + vm.warp(startTime + duration + 1); + + // seller withdraws 25 unsold items + vm.prank(seller); + vm.expectEmit(); + emit DutchAuction.UnsoldAssetsWithdrawn(seller, 25); + auction.withdrawUnsoldAssets(); + + // mock sets inventory to 0 & unsoldWithdrawn = true + assertEq(auction.getInventory(), 0, "inventory should be 0 after withdraw"); + assertTrue(auction.unsoldWithdrawn(), "unsoldWithdrawn should be true"); + } + + function test_withdrawUnsoldAssets_RevertWhen_AuctionNotEnded() external { + // warp to half time and buy 1 item + vm.warp(startTime + (duration / 2)); + vm.prank(buyer1); + auction.buy{value: auction.currentPrice()}(1); + + vm.expectRevert(DutchAuction.AuctionNotEnded.selector); + auction.withdrawUnsoldAssets(); + } + + function test_withdrawUnsoldAssets_RevertWhen_NoUnsoldAssets() external { + // buyer1 buys entire inventory + uint256 price = auction.currentPrice(); + vm.deal(buyer1, price * inventory); + vm.prank(buyer1); + auction.buy{value: price * inventory}(inventory); + + // now inventory=0 => revert + vm.expectRevert(DutchAuction.NoUnsoldAssetsToWithdraw.selector); + auction.withdrawUnsoldAssets(); + } +} diff --git a/test/EnglishAuction.t.sol b/test/EnglishAuction.t.sol index 3cc73d1..4b1fc59 100644 --- a/test/EnglishAuction.t.sol +++ b/test/EnglishAuction.t.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; -import "forge-std/Test.sol"; import {EnglishAuction} from "../src/EnglishAuction.sol"; +import "forge-std/Test.sol"; //////////////////////////////////////////////////////////// // Mock Implementations @@ -184,7 +184,8 @@ contract EnglishAuctionTest is Test { // bidder1 should only get refunded for their losing bid // not for the second bid(which is the highest bid currently) - // A future optimization could allow existing bidders to use funds they already hold in the contract for future bids + // A future optimization could allow existing bidders to use funds they already hold in the contract for future + // bids uint256 bidder1BalanceBefore = bidder1.balance; auction.withdrawRefund(); uint256 bidder1BalanceAfter = bidder1.balance;