From e556d1f66b84a58807a561378da6872cb1636e6a Mon Sep 17 00:00:00 2001 From: MrRaccxxn Date: Sun, 5 Jan 2025 13:32:39 -0400 Subject: [PATCH] enable erc20 tokens for english auction --- src/EnglishAuction.sol | 88 ++++- test/EnglishAuction.t.sol | 724 +++++++++++++++++++++++++++++++++++--- 2 files changed, 763 insertions(+), 49 deletions(-) diff --git a/src/EnglishAuction.sol b/src/EnglishAuction.sol index b226300..417c9d7 100644 --- a/src/EnglishAuction.sol +++ b/src/EnglishAuction.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; +import "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; + /** * @title EnglishAuction * @notice Implements a standard English auction mechanism for a single asset, with: @@ -63,6 +65,12 @@ abstract contract EnglishAuction { uint256 private immutable extensionThreshold; uint256 private immutable extensionPeriod; + /// @dev Indicates if the auction uses native currency or an ERC20 token (Default as native currency) + bool private immutable isNativeCurrency = true; + + /// @dev The ERC20 token used for the auction if `isNativeCurrency` is false + IERC20 public erc20Token; + /// @dev Accumulated proceeds for the seller to withdraw after finalization. uint256 private sellerProceeds; @@ -70,7 +78,12 @@ abstract contract EnglishAuction { /// @param seller The address of the seller. /// @param reservePrice The initial reserve price. /// @param endTime The scheduled end time of the auction. - event AuctionCreated(address indexed seller, uint256 reservePrice, uint256 startTime, uint256 endTime); + event AuctionCreated( + address indexed seller, + uint256 reservePrice, + uint256 startTime, + uint256 endTime + ); /// @notice Emitted when a new highest bid is placed. /// @param bidder The address of the new highest bidder. @@ -125,6 +138,12 @@ abstract contract EnglishAuction { /// @dev Thrown when trying to create an auction with a seller address of zero error InvalidSeller(); + /// @dev Thrown when trying to place a bid with a different currency than the native currency + error OnlyNativeCurrencyBidsAllowed(); + + /// @dev Thrown when trying to place a bid with a different currency than the specified ERC20 token + error OnlyErc20BidsAllowed(); + /// @dev Auction is considered ongoing during [startTime, endTime) modifier auctionOngoing() { if (block.timestamp < startTime) revert AuctionNotStarted(); @@ -147,7 +166,8 @@ abstract contract EnglishAuction { uint256 _startTime, uint256 _duration, uint256 _extensionThreshold, - uint256 _extensionPeriod + uint256 _extensionPeriod, + address _erc20Token ) { if (_seller == address(0)) revert InvalidSeller(); if (_startTime < block.timestamp) revert InvalidStartTime(); @@ -160,6 +180,11 @@ abstract contract EnglishAuction { extensionThreshold = _extensionThreshold; extensionPeriod = _extensionPeriod; + if (_erc20Token != address(0)) { + erc20Token = IERC20(_erc20Token); + isNativeCurrency = false; + } + emit AuctionCreated(seller, highestBid, startTime, endTime); } @@ -175,6 +200,8 @@ abstract contract EnglishAuction { * @custom:hook `_beforeBid` and `_afterBid` can be overridden for custom logic. */ function placeBid() external payable virtual auctionOngoing { + if(!isNativeCurrency) revert OnlyErc20BidsAllowed(); + _beforeBid(msg.sender, msg.value); _validateBidIncrement(msg.value); @@ -197,6 +224,44 @@ abstract contract EnglishAuction { emit NewHighestBid(msg.sender, msg.value); } + function placeErc20Bid(uint256 bidAmount) external virtual auctionOngoing { + if(isNativeCurrency) revert OnlyNativeCurrencyBidsAllowed(); + + _beforeBid(msg.sender, bidAmount); + + _validateBidIncrement(bidAmount); + + // Check if the user's allowance for the contract is sufficient for the bid + require( + erc20Token.allowance(msg.sender, address(this)) >= bidAmount, + "Insufficient allowance" + ); + + bool success = erc20Token.transferFrom( + msg.sender, + address(this), + bidAmount + ); + require(success, "ERC20 transfer failed"); + + // Move the old highest bid into a refundable balance + if (highestBidder != address(0)) { + refunds[highestBidder] += highestBid; + emit RefundAvailable(highestBidder, highestBid); + } + + // Update highest bid + highestBid = bidAmount; + highestBidder = msg.sender; + + // Anti-sniping: if we're close to the end, extend the auction + _maybeExtendAuction(); + + _afterBid(msg.sender, bidAmount); + + emit NewHighestBid(msg.sender, bidAmount); + } + /** * @notice Withdraw refunds owed to the caller due to being outbid. * @dev Reverts if no refund is available. @@ -205,8 +270,13 @@ abstract contract EnglishAuction { uint256 amount = refunds[msg.sender]; refunds[msg.sender] = 0; - (bool success,) = payable(msg.sender).call{value: amount}(""); - require(success, "Transfer failed"); + if (isNativeCurrency) { + (bool success, ) = payable(msg.sender).call{value: amount}(""); + require(success, "Transfer failed"); + } else { + bool success = erc20Token.transfer(msg.sender, amount); + require(success, "ERC20 transfer failed"); + } emit FundsWithdrawn(msg.sender, amount); } @@ -223,8 +293,14 @@ abstract contract EnglishAuction { uint256 amount = sellerProceeds; sellerProceeds = 0; - (bool success,) = payable(seller).call{value: amount}(""); - require(success, "Transfer failed"); + if (isNativeCurrency) { + (bool success, ) = payable(seller).call{value: amount}(""); + require(success, "Transfer failed"); + } else { + bool success = erc20Token.transfer(seller, amount); + require(success, "ERC20 transfer failed"); + } + emit FundsWithdrawn(seller, amount); } diff --git a/test/EnglishAuction.t.sol b/test/EnglishAuction.t.sol index 4b1fc59..c5a473c 100644 --- a/test/EnglishAuction.t.sol +++ b/test/EnglishAuction.t.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; +import "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; + import {EnglishAuction} from "../src/EnglishAuction.sol"; import "forge-std/Test.sol"; @@ -18,8 +20,19 @@ contract MockEnglishAuction is EnglishAuction { uint256 _startTime, uint256 _duration, uint256 _extensionThreshold, - uint256 _extensionPeriod - ) EnglishAuction(_seller, _reservePrice, _startTime, _duration, _extensionThreshold, _extensionPeriod) {} + uint256 _extensionPeriod, + address _erc20Token + ) + EnglishAuction( + _seller, + _reservePrice, + _startTime, + _duration, + _extensionThreshold, + _extensionPeriod, + _erc20Token + ) + {} // Here you would normally transfer the actual asset to the winner(e.g. the NFT) // For testing purposes, we just set the winnerAssetRecipient to the winner @@ -45,8 +58,19 @@ contract MockEnglishAuctionWithIncrement is MockEnglishAuction { uint256 _duration, uint256 _extensionThreshold, uint256 _extensionPeriod, + address _erc20Token, uint256 _incrementRatio - ) MockEnglishAuction(_seller, _reservePrice, _startTime, _duration, _extensionThreshold, _extensionPeriod) { + ) + MockEnglishAuction( + _seller, + _reservePrice, + _startTime, + _duration, + _extensionThreshold, + _extensionPeriod, + _erc20Token + ) + { incrementRatio = _incrementRatio; // 10 means 10% increments required } @@ -78,12 +102,22 @@ contract EnglishAuctionTest is Test { uint256 extensionThreshold = 300; // 5 minutes uint256 extensionPeriod = 600; // 10 minutes + address erc20Token = address(0x0); //To use native currency we can set this to 0x0 + MockEnglishAuction auction; function setUp() external { // Warp to a known start time for consistent testing vm.warp(startTime); - auction = new MockEnglishAuction(seller, reservePrice, startTime, duration, extensionThreshold, extensionPeriod); + auction = new MockEnglishAuction( + seller, + reservePrice, + startTime, + duration, + extensionThreshold, + extensionPeriod, + erc20Token + ); vm.deal(bidder1, 10 ether); vm.deal(bidder2, 10 ether); @@ -95,20 +129,47 @@ contract EnglishAuctionTest is Test { ////////////////////////// function test_setUpState() external { - assertEq(auction.getHighestBid(), reservePrice, "Initial highest bid should match reserve price"); + assertEq( + auction.getHighestBid(), + reservePrice, + "Initial highest bid should match reserve price" + ); uint256 expectedEndTime = block.timestamp + duration; - assertEq(auction.getEndTime(), expectedEndTime, "End time should be start + duration"); - assertEq(auction.getHighestBidder(), address(0), "No highest bidder initially"); - assertEq(auction.isFinalized(), false, "Auction should not be finalized initially"); + assertEq( + auction.getEndTime(), + expectedEndTime, + "End time should be start + duration" + ); + assertEq( + auction.getHighestBidder(), + address(0), + "No highest bidder initially" + ); + assertEq( + auction.isFinalized(), + false, + "Auction should not be finalized initially" + ); } ////////////////////////// // Bidding Tests // ////////////////////////// + function test_placeBid_RevertWhen_BiddingInNativeCurrency() external { + vm.expectRevert(EnglishAuction.OnlyNativeCurrencyBidsAllowed.selector); + auction.placeErc20Bid(1 ether); + } + function test_placeBid_RevertWhen_StartTimeInTheFuture() external { MockEnglishAuction notStartedAuction = new MockEnglishAuction( - seller, reservePrice, block.timestamp + 1, duration, extensionThreshold, extensionPeriod + seller, + reservePrice, + block.timestamp + 1, + duration, + extensionThreshold, + extensionPeriod, + erc20Token ); vm.expectRevert(EnglishAuction.AuctionNotStarted.selector); notStartedAuction.placeBid{value: 1 ether}(); @@ -120,12 +181,22 @@ contract EnglishAuctionTest is Test { vm.stopPrank(); assertEq(auction.getHighestBid(), 2 ether, "Highest bid should update"); - assertEq(auction.getHighestBidder(), bidder1, "Highest bidder should update"); + assertEq( + auction.getHighestBidder(), + bidder1, + "Highest bidder should update" + ); } function test_placeBid_RevertWhen_BidBelowOrEqualReservePrice() external { vm.startPrank(bidder1); - vm.expectRevert(abi.encodeWithSelector(EnglishAuction.BidNotHighEnough.selector, 1 ether, 1 ether)); + vm.expectRevert( + abi.encodeWithSelector( + EnglishAuction.BidNotHighEnough.selector, + 1 ether, + 1 ether + ) + ); auction.placeBid{value: 1 ether}(); vm.stopPrank(); } @@ -137,7 +208,13 @@ contract EnglishAuctionTest is Test { // Second bid tries equal or lower vm.prank(bidder2); - vm.expectRevert(abi.encodeWithSelector(EnglishAuction.BidNotHighEnough.selector, 2 ether, 3 ether)); + vm.expectRevert( + abi.encodeWithSelector( + EnglishAuction.BidNotHighEnough.selector, + 2 ether, + 3 ether + ) + ); auction.placeBid{value: 2 ether}(); } @@ -169,7 +246,11 @@ contract EnglishAuctionTest is Test { auction.withdrawRefund(); uint256 bidder1BalanceAfter = bidder1.balance; - assertEq(bidder1BalanceAfter, bidder1BalanceBefore + 2 ether, "Bidder1 should be refunded"); + assertEq( + bidder1BalanceAfter, + bidder1BalanceBefore + 2 ether, + "Bidder1 should be refunded" + ); } function test_withdrawRefund_OnlyIncludesLosingBids() external { @@ -191,11 +272,15 @@ contract EnglishAuctionTest is Test { uint256 bidder1BalanceAfter = bidder1.balance; assertEq( - bidder1BalanceAfter, bidder1BalanceBefore + 2 ether, "Bidder1 should be refunded with only their losing bid" + bidder1BalanceAfter, + bidder1BalanceBefore + 2 ether, + "Bidder1 should be refunded with only their losing bid" ); } - function test_withdrawRefund_TransfersZeroToCurrentHighestBidder() external { + function test_withdrawRefund_TransfersZeroToCurrentHighestBidder() + external + { vm.startPrank(bidder1); auction.placeBid{value: 2 ether}(); @@ -203,7 +288,11 @@ contract EnglishAuctionTest is Test { auction.withdrawRefund(); uint256 bidder1BalanceAfter = bidder1.balance; - assertEq(bidder1BalanceAfter, bidder1BalanceBefore, "Bidder1 should be refunded with zero"); + assertEq( + bidder1BalanceAfter, + bidder1BalanceBefore, + "Bidder1 should be refunded with zero" + ); } function test_withdrawSellerProceeds_Works() external { @@ -218,10 +307,16 @@ contract EnglishAuctionTest is Test { auction.withdrawSellerProceeds(); uint256 sellerBalanceAfter = seller.balance; - assertEq(sellerBalanceAfter, sellerBalanceBefore + 2 ether, "Seller should receive funds"); + assertEq( + sellerBalanceAfter, + sellerBalanceBefore + 2 ether, + "Seller should receive funds" + ); } - function test_withdrawSellerProceeds_TransfersZeroWhenNoProceeds() external { + function test_withdrawSellerProceeds_TransfersZeroWhenNoProceeds() + external + { vm.startPrank(seller); vm.warp(block.timestamp + duration + 1); auction.finalizeAuction(); @@ -230,7 +325,11 @@ contract EnglishAuctionTest is Test { auction.withdrawSellerProceeds(); uint256 sellerBalanceAfter = seller.balance; - assertEq(sellerBalanceAfter, sellerBalanceBefore, "Seller should receive zero"); + assertEq( + sellerBalanceAfter, + sellerBalanceBefore, + "Seller should receive zero" + ); } // ////////////////////////// @@ -244,7 +343,11 @@ contract EnglishAuctionTest is Test { vm.warp(block.timestamp + duration + 1); auction.finalizeAuction(); - assertEq(auction.winnerAssetRecipient(), bidder1, "Asset should be transferred to bidder1"); + assertEq( + auction.winnerAssetRecipient(), + bidder1, + "Asset should be transferred to bidder1" + ); } function test_finalizeAuction_RevertWhen_AuctionNotYetEnded() external { @@ -265,7 +368,11 @@ contract EnglishAuctionTest is Test { bool isFinalized = auction.isFinalized(); assertEq(isFinalized, true, "Auction should be finalized"); // No winner - assertEq(auction.winnerAssetRecipient(), address(0), "No winner if no bids"); + assertEq( + auction.winnerAssetRecipient(), + address(0), + "No winner if no bids" + ); } function test_finalizeAuction_RevertWhen_AlreadyFinalized() external { @@ -293,7 +400,11 @@ contract EnglishAuctionTest is Test { auction.placeBid{value: 2 ether}(); uint256 endTimeAfter = auction.getEndTime(); - assertEq(endTimeAfter, endTimeBefore + extensionPeriod, "Should extend the auction end time"); + assertEq( + endTimeAfter, + endTimeBefore + extensionPeriod, + "Should extend the auction end time" + ); } function test_placeBidDoesNotExtendWhenNotWithinThreshold() external { @@ -312,8 +423,15 @@ contract EnglishAuctionTest is Test { function test_placeBidDoesNotExtendWhenThresholdIsZero() external { // Deploy a new auction with zero threshold - MockEnglishAuction noExtendAuction = - new MockEnglishAuction(seller, reservePrice, startTime, duration, 0, extensionPeriod); + MockEnglishAuction noExtendAuction = new MockEnglishAuction( + seller, + reservePrice, + startTime, + duration, + 0, + extensionPeriod, + erc20Token + ); uint256 endTimeBefore = noExtendAuction.getEndTime(); // Warp close to end @@ -324,13 +442,24 @@ contract EnglishAuctionTest is Test { // Check no extension uint256 endTimeAfter = noExtendAuction.getEndTime(); - assertEq(endTimeAfter, endTimeBefore, "No extension if threshold is zero"); + assertEq( + endTimeAfter, + endTimeBefore, + "No extension if threshold is zero" + ); } function test_placeBidDoesNotExtendWhenExtensionPeriodIsZero() external { // Deploy a new auction with zero extension period - MockEnglishAuction noExtendAuction = - new MockEnglishAuction(seller, reservePrice, startTime, duration, extensionThreshold, 0); + MockEnglishAuction noExtendAuction = new MockEnglishAuction( + seller, + reservePrice, + startTime, + duration, + extensionThreshold, + 0, + erc20Token + ); uint256 endTimeBefore = noExtendAuction.getEndTime(); // Warp close to end @@ -341,7 +470,11 @@ contract EnglishAuctionTest is Test { // Check no extension uint256 endTimeAfter = noExtendAuction.getEndTime(); - assertEq(endTimeAfter, endTimeBefore, "No extension if extension period is zero"); + assertEq( + endTimeAfter, + endTimeBefore, + "No extension if extension period is zero" + ); } // ////////////////////////// @@ -351,14 +484,15 @@ contract EnglishAuctionTest is Test { function test_placeBidWithIncrement_RevertWhen_BidNotHighEnough() external { // Deploy auction with 10 % increment requirement MockEnglishAuctionWithIncrement incrementAuction = new MockEnglishAuctionWithIncrement( - seller, - reservePrice, - startTime, - duration, - extensionThreshold, - extensionPeriod, - 10 // 10% increment - ); + seller, + reservePrice, + startTime, + duration, + extensionThreshold, + extensionPeriod, + erc20Token, + 10 // 10% increment + ); vm.prank(bidder1); incrementAuction.placeBid{value: 2 ether}(); @@ -366,30 +500,534 @@ contract EnglishAuctionTest is Test { // Bidder2 tries to outbid with only 2.1 ether (less than 10% increment over 2 ether) // 10% of 2 ether is 0.2 ether, so must be at least 2.2 ether vm.prank(bidder2); - vm.expectRevert(abi.encodeWithSelector(EnglishAuction.BidNotHighEnough.selector, 2.1 ether, 2.2 ether)); + vm.expectRevert( + abi.encodeWithSelector( + EnglishAuction.BidNotHighEnough.selector, + 2.1 ether, + 2.2 ether + ) + ); incrementAuction.placeBid{value: 2.1 ether}(); } function test_placeBidWithIncrement_Works() external { // Deploy auction with 10 % increment requirement MockEnglishAuctionWithIncrement incrementAuction = new MockEnglishAuctionWithIncrement( + seller, + reservePrice, + startTime, + duration, + extensionThreshold, + extensionPeriod, + erc20Token, + 10 // 10% increment + ); + + vm.prank(bidder1); + incrementAuction.placeBid{value: 2 ether}(); + + // Bidder2 places a valid bid (2.5 ether meets 10% increment) + vm.prank(bidder2); + incrementAuction.placeBid{value: 2.5 ether}(); + + assertEq( + incrementAuction.getHighestBidder(), + bidder2, + "Bidder2 should now be highest bidder" + ); + assertEq( + incrementAuction.getHighestBid(), + 2.5 ether, + "Highest bid should be updated" + ); + } +} + +contract MockERC20 is ERC20 { + constructor( + string memory name, + string memory symbol, + uint256 initialSupply + ) ERC20(name, symbol) { + _mint(msg.sender, initialSupply); + } +} + +contract Erc20EnglishAuctionTest is Test { + address seller = address(0xA1); + address bidder1 = address(0xB1); + address bidder2 = address(0xB2); + address randomUser = address(0xC1); + + uint256 reservePrice = 1 ether; + uint256 duration = 1 days; + uint256 startTime = 1000; + uint256 extensionThreshold = 300; // 5 minutes + uint256 extensionPeriod = 600; // 10 minutes + + IERC20 erc20Token; //To use native currency we can set this to 0x0 + + MockEnglishAuction auction; + + function setUp() external { + MockERC20 mockERC20 = new MockERC20("Test Token", "TT", 21 ether); + + // Warp to a known start time for consistent testing + vm.warp(startTime); + auction = new MockEnglishAuction( seller, reservePrice, startTime, duration, extensionThreshold, extensionPeriod, - 10 // 10% increment + address(mockERC20) + ); + + erc20Token = IERC20(address(mockERC20)); + + deal(address(mockERC20), bidder1, 10 ether); + deal(address(mockERC20), bidder2, 10 ether); + deal(address(mockERC20), randomUser, 1 ether); + } + + function test_setUpState() external { + assertEq( + auction.getHighestBid(), + reservePrice, + "Initial highest bid should match reserve price" + ); + uint256 expectedEndTime = block.timestamp + duration; + assertEq( + auction.getEndTime(), + expectedEndTime, + "End time should be start + duration" + ); + assertEq( + auction.getHighestBidder(), + address(0), + "No highest bidder initially" + ); + assertEq( + auction.isFinalized(), + false, + "Auction should not be finalized initially" + ); + } + + ////////////////////////// + // Bidding Tests // + ////////////////////////// + + function test_placeBid_RevertWhen_BiddingInNativeCurrency() external { + vm.expectRevert(EnglishAuction.OnlyErc20BidsAllowed.selector); + auction.placeBid{value: 1 ether}(); + } + + function test_placeBid_RevertWhen_StartTimeInTheFuture() external { + MockEnglishAuction notStartedAuction = new MockEnglishAuction( + seller, + reservePrice, + block.timestamp + 1, + duration, + extensionThreshold, + extensionPeriod, + address(erc20Token) + ); + vm.expectRevert(EnglishAuction.AuctionNotStarted.selector); + notStartedAuction.placeBid{value: 1 ether}(); + } + + function test_placeBidUpdatesHighestBid() external { + vm.startPrank(bidder1); + erc20Token.approve(address(auction), 2 ether); + auction.placeErc20Bid(2 ether); + vm.stopPrank(); + + assertEq(auction.getHighestBid(), 2 ether, "Highest bid should update"); + assertEq( + auction.getHighestBidder(), + bidder1, + "Highest bidder should update" ); + } + function test_placeBid_RevertWhen_BidBelowOrEqualReservePrice() external { vm.prank(bidder1); - incrementAuction.placeBid{value: 2 ether}(); + vm.expectRevert( + abi.encodeWithSelector( + EnglishAuction.BidNotHighEnough.selector, + 1 ether, + 1 ether + ) + ); + auction.placeErc20Bid(1 ether); + vm.stopPrank(); + } - // Bidder2 places a valid bid (2.5 ether meets 10% increment) + function test_placeBid_RevertWhen_BidNotHigherThanCurrent() external { + // First valid bid + vm.startPrank(bidder1); + erc20Token.approve(address(auction), 3 ether); + auction.placeErc20Bid(3 ether); + vm.stopPrank(); + + // Second bid tries equal or lower vm.prank(bidder2); - incrementAuction.placeBid{value: 2.5 ether}(); + vm.expectRevert( + abi.encodeWithSelector( + EnglishAuction.BidNotHighEnough.selector, + 2 ether, + 3 ether + ) + ); + auction.placeErc20Bid(2 ether); + } + + function test_placeBid_RevertWhen_AuctionEnded() external { + vm.warp(startTime + duration + 1); + vm.prank(bidder1); + vm.expectRevert(EnglishAuction.AuctionEnded.selector); + auction.placeErc20Bid(2 ether); + } - assertEq(incrementAuction.getHighestBidder(), bidder2, "Bidder2 should now be highest bidder"); - assertEq(incrementAuction.getHighestBid(), 2.5 ether, "Highest bid should be updated"); + ////////////////////////// + // Refund & Withdrawal // + ////////////////////////// + + function test_withdrawRefund_Works() external { + // bidder1 makes first bid + vm.startPrank(bidder1); + erc20Token.approve(address(auction), 2 ether); + auction.placeErc20Bid(2 ether); + vm.stopPrank(); + + // bidder2 outbids bidder1 + vm.startPrank(bidder2); + erc20Token.approve(address(auction), 3 ether); + auction.placeErc20Bid(3 ether); + vm.stopPrank(); + + // bidder1 should have a refund available + uint256 bidder1BalanceBefore = erc20Token.balanceOf(bidder1); + vm.startPrank(bidder1); + auction.withdrawRefund(); + uint256 bidder1BalanceAfter = erc20Token.balanceOf(bidder1); + + assertEq( + bidder1BalanceAfter, + bidder1BalanceBefore + 2 ether, + "Bidder1 should be refunded" + ); + } + + function test_withdrawRefund_OnlyIncludesLosingBids() external { + vm.startPrank(bidder1); + erc20Token.approve(address(auction), 2 ether); + auction.placeErc20Bid(2 ether); + vm.stopPrank(); + + vm.startPrank(bidder2); + erc20Token.approve(address(auction), 3 ether); + auction.placeErc20Bid(3 ether); + vm.stopPrank(); + + vm.startPrank(bidder1); + erc20Token.approve(address(auction), 4 ether); + auction.placeErc20Bid(4 ether); + + // 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 + uint256 bidder1BalanceBefore = erc20Token.balanceOf(bidder1); + auction.withdrawRefund(); + uint256 bidder1BalanceAfter = erc20Token.balanceOf(bidder1); + + assertEq( + bidder1BalanceAfter, + bidder1BalanceBefore + 2 ether, + "Bidder1 should be refunded with only their losing bid" + ); + } + + function test_withdrawRefund_TransfersZeroToCurrentHighestBidder() + external + { + vm.startPrank(bidder1); + erc20Token.approve(address(auction), 2 ether); + auction.placeErc20Bid(2 ether); + + uint256 bidder1BalanceBefore = erc20Token.balanceOf(bidder1); + auction.withdrawRefund(); + uint256 bidder1BalanceAfter = erc20Token.balanceOf(bidder1); + + assertEq( + bidder1BalanceAfter, + bidder1BalanceBefore, + "Bidder1 should be refunded with zero" + ); + } + + function test_withdrawSellerProceeds_Works() external { + vm.startPrank(bidder1); + erc20Token.approve(address(auction), 2 ether); + auction.placeErc20Bid(2 ether); + + vm.startPrank(seller); + vm.warp(block.timestamp + duration + 1); + auction.finalizeAuction(); + + uint256 sellerBalanceBefore = erc20Token.balanceOf(seller); + auction.withdrawSellerProceeds(); + uint256 sellerBalanceAfter = erc20Token.balanceOf(seller); + + assertEq( + sellerBalanceAfter, + sellerBalanceBefore + 2 ether, + "Seller should receive funds" + ); + } + + function test_withdrawSellerProceeds_TransfersZeroWhenNoProceeds() + external + { + vm.startPrank(seller); + vm.warp(block.timestamp + duration + 1); + auction.finalizeAuction(); + + uint256 sellerBalanceBefore = erc20Token.balanceOf(seller); + auction.withdrawSellerProceeds(); + uint256 sellerBalanceAfter = erc20Token.balanceOf(seller); + + assertEq( + sellerBalanceAfter, + sellerBalanceBefore, + "Seller should receive zero" + ); + } + + // ////////////////////////// + // // Finalize Auction // + // ////////////////////////// + + function test_finalizeAuction_TransfersAssetToWinner() external { + vm.startPrank(bidder1); + erc20Token.approve(address(auction), 2 ether); + auction.placeErc20Bid(2 ether); + + vm.warp(block.timestamp + duration + 1); + auction.finalizeAuction(); + + assertEq( + auction.winnerAssetRecipient(), + bidder1, + "Asset should be transferred to bidder1" + ); + } + + function test_finalizeAuction_RevertWhen_AuctionNotYetEnded() external { + vm.startPrank(bidder1); + erc20Token.approve(address(auction), 2 ether); + auction.placeErc20Bid(2 ether); + + vm.expectRevert(EnglishAuction.AuctionNotYetEnded.selector); + auction.finalizeAuction(); + } + + function test_finalizeAuctionWorksWithNoBids() external { + // No one bids, just end the auction + vm.warp(block.timestamp + duration + 1); + + // finalize + auction.finalizeAuction(); + + bool isFinalized = auction.isFinalized(); + assertEq(isFinalized, true, "Auction should be finalized"); + // No winner + assertEq( + auction.winnerAssetRecipient(), + address(0), + "No winner if no bids" + ); + } + + function test_finalizeAuction_RevertWhen_AlreadyFinalized() external { + vm.startPrank(bidder1); + erc20Token.approve(address(auction), 2 ether); + auction.placeErc20Bid(2 ether); + + vm.warp(block.timestamp + duration + 1); + auction.finalizeAuction(); + + vm.expectRevert(EnglishAuction.AuctionAlreadyFinalized.selector); + auction.finalizeAuction(); + } + + // ////////////////////////// + // // Anti-Sniping Tests // + // ////////////////////////// + + function test_placeBidExtendsAuctionNearEnd() external { + // Move close to the end + uint256 endTimeBefore = auction.getEndTime(); + vm.warp(endTimeBefore - (extensionThreshold - 1)); + + // Place a bid to trigger extension + vm.startPrank(bidder1); + erc20Token.approve(address(auction), 2 ether); + auction.placeErc20Bid(2 ether); + + uint256 endTimeAfter = auction.getEndTime(); + assertEq( + endTimeAfter, + endTimeBefore + extensionPeriod, + "Should extend the auction end time" + ); + } + + function test_placeBidDoesNotExtendWhenNotWithinThreshold() external { + // Warp at the threshold(should not extend) + uint256 endTimeBefore = auction.getEndTime(); + vm.warp(endTimeBefore - extensionThreshold); + + // Place a bid + vm.startPrank(bidder1); + erc20Token.approve(address(auction), 2 ether); + auction.placeErc20Bid(2 ether); + + // Should not extend + uint256 endTimeAfter = auction.getEndTime(); + assertEq(endTimeAfter, endTimeBefore, "No extension should occur"); + } + + function test_placeBidDoesNotExtendWhenThresholdIsZero() external { + // Deploy a new auction with zero threshold + MockEnglishAuction noExtendAuction = new MockEnglishAuction( + seller, + reservePrice, + startTime, + duration, + 0, + extensionPeriod, + address(erc20Token) + ); + uint256 endTimeBefore = noExtendAuction.getEndTime(); + + // Warp close to end + vm.warp(endTimeBefore - 1); + + vm.startPrank(bidder1); + erc20Token.approve(address(noExtendAuction), 2 ether); + noExtendAuction.placeErc20Bid(2 ether); + + // Check no extension + uint256 endTimeAfter = noExtendAuction.getEndTime(); + assertEq( + endTimeAfter, + endTimeBefore, + "No extension if threshold is zero" + ); + } + + function test_placeBidDoesNotExtendWhenExtensionPeriodIsZero() external { + // Deploy a new auction with zero extension period + MockEnglishAuction noExtendAuction = new MockEnglishAuction( + seller, + reservePrice, + startTime, + duration, + extensionThreshold, + 0, + address(erc20Token) + ); + uint256 endTimeBefore = noExtendAuction.getEndTime(); + + // Warp close to end + vm.warp(endTimeBefore - 1); + + vm.startPrank(bidder1); + erc20Token.approve(address(noExtendAuction), 2 ether); + noExtendAuction.placeErc20Bid(2 ether); + vm.stopPrank(); + + // Check no extension + uint256 endTimeAfter = noExtendAuction.getEndTime(); + assertEq( + endTimeAfter, + endTimeBefore, + "No extension if extension period is zero" + ); + } + + // ////////////////////////// + // // Increment Tests // + // ////////////////////////// + + function test_placeBidWithIncrement_RevertWhen_BidNotHighEnough() external { + // Deploy auction with 10 % increment requirement + MockEnglishAuctionWithIncrement incrementAuction = new MockEnglishAuctionWithIncrement( + seller, + reservePrice, + startTime, + duration, + extensionThreshold, + extensionPeriod, + address(erc20Token), + 10 // 10% increment + ); + + vm.startPrank(bidder1); + erc20Token.approve(address(incrementAuction), 2 ether); + incrementAuction.placeErc20Bid(2 ether); + vm.stopPrank(); + + // Bidder2 tries to outbid with only 2.1 ether (less than 10% increment over 2 ether) + // 10% of 2 ether is 0.2 ether, so must be at least 2.2 ether + vm.prank(bidder2); + vm.expectRevert( + abi.encodeWithSelector( + EnglishAuction.BidNotHighEnough.selector, + 2.1 ether, + 2.2 ether + ) + ); + incrementAuction.placeErc20Bid(2.1 ether); + } + + function test_placeBidWithIncrement_Works() external { + // Deploy auction with 10 % increment requirement + MockEnglishAuctionWithIncrement incrementAuction = new MockEnglishAuctionWithIncrement( + seller, + reservePrice, + startTime, + duration, + extensionThreshold, + extensionPeriod, + address(erc20Token), + 10 // 10% increment + ); + + vm.startPrank(bidder1); + erc20Token.approve(address(incrementAuction), 2 ether); + incrementAuction.placeErc20Bid(2 ether); + vm.stopPrank(); + + // Bidder2 places a valid bid (2.5 ether meets 10% increment) + vm.startPrank(bidder2); + erc20Token.approve(address(incrementAuction), 2.5 ether); + incrementAuction.placeErc20Bid(2.5 ether); + vm.stopPrank(); + + assertEq( + incrementAuction.getHighestBidder(), + bidder2, + "Bidder2 should now be highest bidder" + ); + assertEq( + incrementAuction.getHighestBid(), + 2.5 ether, + "Highest bid should be updated" + ); } }