diff --git a/src/Auction.sol b/src/Auction.sol new file mode 100644 index 0000000..b332d02 --- /dev/null +++ b/src/Auction.sol @@ -0,0 +1,278 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; + +/// @title USDC-KWENTA Auction Contract +/// @author Flocqst (florian@kwenta.io) +contract Auction is Ownable, Initializable { + /*////////////////////////////////////////////////////////////// + EVENTS + //////////////////////////////////////////////////////////////*/ + + /// @notice Emitted when the auction starts + event Start(); + + /// @notice Emitted when a bid is placed + /// @param sender The address of the bidder + /// @param amount The amount of the bid + event Bid(address indexed sender, uint256 amount); + + /// @notice Emitted when a bidder withdraws their non-winning bids + /// @param bidder The address of the bidder + /// @param amount The amount of funds withdrawn + event Withdraw(address indexed bidder, uint256 amount); + + /// @notice Emitted when the auction ends + /// @param winner The address of the winner + /// @param amount The amount of the winning bid + event End(address winner, uint256 amount); + + /// @notice Emitted when the bid increment is updated + /// @param newBidIncrement The new bid increment value + event BidBufferUpdated(uint256 newBidIncrement); + + /// @notice Emitted when bidding is frozen + event BiddingFrozen(); + + /// @notice Emitted when bidding is resumed + event BiddingResumed(); + + /// @notice Emitted when funds are withdrawn by the owner + /// @param owner The address of the owner + /// @param usdcAmount The amount of USDC withdrawn + /// @param kwentaAmount The amount of KWENTA withdrawn + event FundsWithdrawn( + address indexed owner, uint256 usdcAmount, uint256 kwentaAmount + ); + + /*////////////////////////////////////////////////////////////// + ERRORS + //////////////////////////////////////////////////////////////*/ + + /// @notice Thrown when trying to start the auction when it is already started + error AuctionAlreadyStarted(); + + /// @notice Thrown when trying to bid or settle on an auction that has not started yet + error AuctionNotStarted(); + + /// @notice Thrown when trying to bid on an auction that has already ended + error AuctionAlreadyEnded(); + + /// @notice Throw when the bid amount is too low to be accepted + /// @param highestBidPlusBuffer The required minimum bid amount + error BidTooLow(uint256 highestBidPlusBuffer); + + /// @notice Thrown when trying to settle an auction that has not ended yet + error AuctionNotEnded(); + + /// @notice Thrown when trying to settle an auction that has already been settled + error AuctionAlreadySettled(); + + /// @notice Thrown when trying to froze bidding when it is already frozen + error BiddingFrozenErr(); + + /*////////////////////////////////////////////////////////////// + STATE VARIABLES + //////////////////////////////////////////////////////////////*/ + + /// @notice Contract for USDC ERC20 token + IERC20 public usdc; + + /// @notice Contract for KWENTA ERC20 token + IERC20 public kwenta; + + /// @notice The amount of USDC to be auctioned + uint256 public auctionAmount; + + /// @notice The starting bid amount + uint256 public startingBid; + + /// @notice The minimum amount that a bid must be above the current highest bid + uint256 public bidBuffer; + + /// @notice The timestamp at which the auction ends + uint256 public endAt; + + /// @notice Indicates if the auction has started. + bool public started; + + /// @notice Indicates if the auction has been settled. + bool public settled; + + /// @notice Indicates if bidding is frozen + bool public frozen; + + /// @notice The address of the highest bidder + address public highestBidder; + + /// @notice The amount of the highest bid + uint256 public highestBid; + + /// @notice Mapping of bidders to their bids + mapping(address => uint256) public bids; + + /*/////////////////////////////////////////////////////////////// + CONSTRUCTOR / INITIALIZER + ///////////////////////////////////////////////////////////////*/ + + /// @dev Actual contract construction will take place in the initialize function via proxy + /// @param initialOwner The address of the owner of this contract + /// @param _usdc The address for the USDC ERC20 token + /// @param _kwenta The address for the KWENTA ERC20 token + /// @param _startingBid The starting bid amount + /// @param _bidBuffer The initial bid buffer amount + constructor( + address initialOwner, + address _usdc, + address _kwenta, + uint256 _startingBid, + uint256 _bidBuffer + ) Ownable(initialOwner) { + usdc = IERC20(_usdc); + kwenta = IERC20(_kwenta); + + highestBid = _startingBid; + bidBuffer = _bidBuffer; + } + + /// @notice Initializes the auction contract + /// @param initialOwner The address of the owner of this contract + /// @param _usdc The address for the USDC ERC20 token + /// @param _kwenta The address for the KWENTA ERC20 token + /// @param _startingBid The starting bid amount + /// @param _bidBuffer The initial bid buffer amount + function initialize( + address initialOwner, + address _usdc, + address _kwenta, + uint256 _startingBid, + uint256 _bidBuffer + ) public initializer { + _transferOwnership(initialOwner); + + usdc = IERC20(_usdc); + kwenta = IERC20(_kwenta); + + highestBid = _startingBid; + bidBuffer = _bidBuffer; + } + + /*/////////////////////////////////////////////////////////////// + AUCTION OPERATIONS + ///////////////////////////////////////////////////////////////*/ + + /// @notice Starts the auction + /// @param _auctionAmount The amount of USDC to be auctioned + /// @dev Can only be called by the owner once + function start(uint256 _auctionAmount) external onlyOwner { + if (started) revert AuctionAlreadyStarted(); + + usdc.transferFrom(msg.sender, address(this), _auctionAmount); + auctionAmount = _auctionAmount; + + started = true; + endAt = block.timestamp + 1 days; + + emit Start(); + } + + /// @notice Places a bid in the auction. + /// @param amount The amount of KWENTA to bid. + /// @dev The auction must be started, not ended, and the bid must be higher than the current highest bid plus buffer + function bid(uint256 amount) external isFrozen { + if (!started) revert AuctionNotStarted(); + if (block.timestamp >= endAt) revert AuctionAlreadyEnded(); + if (amount < highestBid + bidBuffer) { + revert BidTooLow(highestBid + bidBuffer); + } + + kwenta.transferFrom(msg.sender, address(this), amount); + + if (highestBidder != address(0)) { + bids[highestBidder] += highestBid; + } + + highestBidder = msg.sender; + highestBid = amount; + + // Extend the auction if it is ending in less than an hour + if (endAt - block.timestamp < 1 hours) { + endAt = block.timestamp + 1 hours; + } + + emit Bid(msg.sender, amount); + } + + /// @notice Withdraws the callers non-winning bids + function withdraw() external { + uint256 bal = bids[msg.sender]; + bids[msg.sender] = 0; + + kwenta.transfer(msg.sender, bal); + + emit Withdraw(msg.sender, bal); + } + + /// @notice Settles the auction + function settleAuction() external { + if (!started) revert AuctionNotStarted(); + if (block.timestamp < endAt) revert AuctionNotEnded(); + if (settled) revert AuctionAlreadySettled(); + + settled = true; + + if (highestBidder != address(0)) { + usdc.transfer(highestBidder, auctionAmount); + kwenta.transfer(owner(), highestBid); + } else { + usdc.transfer(owner(), auctionAmount); + } + + emit End(highestBidder, highestBid); + } + + /// @notice Updates the minimum bid increment + /// @param _bidBuffer The new bid buffer value + function setBidIncrement(uint256 _bidBuffer) external onlyOwner { + bidBuffer = _bidBuffer; + emit BidBufferUpdated(_bidBuffer); + } + + /// @notice Modifier to ensure that bidding is not frozen + modifier isFrozen() { + if (frozen) revert BiddingFrozenErr(); + _; + } + + /// @notice Freeze bidding, preventing any new bids + function freezeBidding() external onlyOwner { + frozen = true; + emit BiddingFrozen(); + } + + /// @notice Resume bidding, allowing new bids to be placed + function resumeBidding() external onlyOwner { + frozen = false; + emit BiddingResumed(); + } + + /// @notice Withdraws all funds from the contract + /// @dev Only callable by the owner. This is a safety feature only to be used in emergencies + function withdrawFunds() external onlyOwner { + uint256 usdcBalance = usdc.balanceOf(address(this)); + uint256 kwentaBalance = kwenta.balanceOf(address(this)); + + if (usdcBalance > 0) { + usdc.transfer(owner(), usdcBalance); + } + + if (kwentaBalance > 0) { + kwenta.transfer(owner(), kwentaBalance); + } + + emit FundsWithdrawn(owner(), usdcBalance, kwentaBalance); + } +} diff --git a/src/AuctionFactory.sol b/src/AuctionFactory.sol new file mode 100644 index 0000000..e9e3693 --- /dev/null +++ b/src/AuctionFactory.sol @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.25; + +import {Auction} from "./Auction.sol"; +import "@openzeppelin/contracts/proxy/Clones.sol"; + +/// @title Auction Factory Contract for USDC-KWENTA Auctions +/// @author Flocqst (florian@kwenta.io) +contract AuctionFactory { + /// @notice Kwenta owned/operated multisig address that + /// can authorize upgrades + /// @dev making immutable because the pDAO address + /// will *never* change + address internal immutable pDAO; + + /// @notice Address of the auction implementation contract + address public auctionImplementation; + + /// @notice Array of all auctions created + address[] public auctions; + + /// @notice Bid buffer amount used for all auctions + uint256 public bidBuffer; + + /// @notice thrown when attempting to update + /// the bidBuffer when caller is not the Kwenta pDAO + error OnlyPDAO(); + + /// @notice Emitted when a new auction is created + /// @param auctionContract The address of the newly created auction contract + /// @param owner The address of the account that created the auction + /// @param numAuctions The total number of auctions created + /// @param allAuctions Array of all auction contract addresses + event AuctionCreated( + address auctionContract, + address owner, + uint256 numAuctions, + address[] allAuctions + ); + + /// @notice Emitted when the bid buffer is updated + /// @param _newBidBuffer The new bid buffer value + event BidBufferUpdated(uint256 _newBidBuffer); + + /// @notice Modifier to restrict access to pDAO only + modifier onlyPDAO() { + if (msg.sender != pDAO) revert OnlyPDAO(); + _; + } + + /// @notice Constructs the AuctionFactory with the address of the auction implementation contract + /// @param _pDAO Kwenta owned/operated multisig address + /// @param _auctionImplementation The address of the auction implementation contract + constructor(address _pDAO, address _auctionImplementation) { + pDAO = _pDAO; + auctionImplementation = _auctionImplementation; + } + + /// @notice Creates a new auction by cloning the auction implementation contract + /// @param _owner The address of the DAO that owns the auction + /// @param _usdc The address for the USDC ERC20 token + /// @param _kwenta The address for the KWENTA ERC20 token + /// @param _startingBid The starting bid amount + /// @return newAuction The newly created auction contract + /// @dev The newly created auction contract is initialized and added to the auctions array and returned + function createAuction( + address _owner, + address _usdc, + address _kwenta, + uint256 _startingBid + ) external returns (Auction newAuction) { + address clone = Clones.clone(auctionImplementation); + Auction(clone).initialize( + _owner, _usdc, _kwenta, _startingBid, bidBuffer + ); + newAuction = + new Auction(_owner, _usdc, _kwenta, _startingBid, bidBuffer); + auctions.push(address(newAuction)); + + emit AuctionCreated( + address(newAuction), msg.sender, auctions.length, auctions + ); + } + + /// @notice Updates the bid buffer amount + /// @param _newBidBuffer The new bid buffer value to set + /// @dev Only callable by pDAO + function updateBidBuffer(uint256 _newBidBuffer) external onlyPDAO { + bidBuffer = _newBidBuffer; + emit BidBufferUpdated(_newBidBuffer); + } + + /// @notice Returns the array of all auction contract addresses + function getAllAuctions() external view returns (address[] memory) { + return auctions; + } +} diff --git a/test/Auction.t.sol b/test/Auction.t.sol new file mode 100644 index 0000000..b07da24 --- /dev/null +++ b/test/Auction.t.sol @@ -0,0 +1,421 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.25; + +import {Test} from "forge-std/Test.sol"; +import {Auction} from "../src/Auction.sol"; +import {MockERC20} from "./mocks/MockERC20.sol"; +import {MockUSDC} from "./mocks/MockUSDC.sol"; +import {Constants} from "./utils/Constants.sol"; +import {ConsolidatedEvents} from "./utils/ConsolidatedEvents.sol"; + +contract AuctionTest is Test, Constants, ConsolidatedEvents { + Auction public auction; + MockUSDC public usdc; + MockERC20 public kwenta; + + function setUp() public { + usdc = new MockUSDC(); + kwenta = new MockERC20("KWENTA", "KWENTA"); + + usdc.mint(OWNER, AUCTION_TEST_VALUE); + kwenta.mint(ACTOR1, TEST_VALUE); + kwenta.mint(ACTOR2, TEST_VALUE); + + // Deploy Auction contract and start auction + vm.prank(OWNER); + auction = new Auction( + OWNER, address(usdc), address(kwenta), STARTING_BID, BID_BUFFER + ); + } + + /*////////////////////////////////////////////////////////////// + start + //////////////////////////////////////////////////////////////*/ + + function test_start_auction(uint256 amount) public { + vm.assume(amount <= AUCTION_TEST_VALUE); + // Start the auction + startAuction(amount); + + // Asserts auction has been correctly started + assertTrue(auction.started()); + assertEq(auction.auctionAmount(), amount); + assertEq(usdc.balanceOf(address(auction)), amount); + } + + function test_cannot_start_auction_already_started() public { + // Start the auction + startAuction(AUCTION_TEST_VALUE); + assertTrue(auction.started()); + + // Try starting the auction twice + vm.prank(OWNER); + vm.expectRevert(Auction.AuctionAlreadyStarted.selector); + auction.start(AUCTION_TEST_VALUE); + } + + function test_start_event() public { + // Start the auction + vm.startPrank(OWNER); + usdc.approve(address(auction), AUCTION_TEST_VALUE); + + vm.expectEmit(true, true, true, true); + emit Start(); + + auction.start(AUCTION_TEST_VALUE); + vm.stopPrank(); + } + + /*////////////////////////////////////////////////////////////// + bid + //////////////////////////////////////////////////////////////*/ + + function test_bid(uint256 amount) public { + startAuction(AUCTION_TEST_VALUE); + + assertEq(auction.highestBid(), STARTING_BID); + + // bidding should revert if actor has insufficient balance + if (amount > TEST_VALUE) { + vm.startPrank(ACTOR1); + kwenta.approve(address(auction), amount); + + vm.expectRevert(); + auction.bid(amount); + vm.stopPrank(); + } else { + vm.startPrank(ACTOR1); + kwenta.approve(address(auction), amount); + + // bidding should revert if amount < highestBid + bidBuffer + if (amount < auction.highestBid() + BID_BUFFER) { + vm.expectRevert( + abi.encodeWithSelector( + Auction.BidTooLow.selector, + auction.highestBid() + BID_BUFFER + ) + ); + auction.bid(amount); + } else { + auction.bid(amount); + + // Asserts bid has been correctly placed + assertEq(auction.highestBid(), amount); + assertEq(auction.highestBidder(), ACTOR1); + assertEq(kwenta.balanceOf(address(auction)), amount); + } + vm.stopPrank(); + } + } + + function test_bid_updates_highest_bid_and_bidder( + uint256 firstBidAmount, + uint256 secondBidAmount + ) public { + firstBidAmount = bound( + firstBidAmount, STARTING_BID + BID_BUFFER, TEST_VALUE - BID_BUFFER + ); + secondBidAmount = + bound(secondBidAmount, firstBidAmount + BID_BUFFER, TEST_VALUE); + + startAuction(AUCTION_TEST_VALUE); + + // Place first bid + placeBid(ACTOR1, firstBidAmount); + + assertEq(auction.highestBid(), firstBidAmount); + assertEq(auction.highestBidder(), ACTOR1); + assertEq(kwenta.balanceOf(address(auction)), firstBidAmount); + + // Place second bid + placeBid(ACTOR2, secondBidAmount); + + // Asserts highest bid and highest bidder has been updated + assertEq(auction.highestBid(), secondBidAmount); + assertEq(auction.highestBidder(), ACTOR2); + + assertEq( + kwenta.balanceOf(address(auction)), firstBidAmount + secondBidAmount + ); + } + + function test_bid_extends_auction() public { + startAuction(AUCTION_TEST_VALUE); + + assertEq(auction.endAt(), block.timestamp + 1 days); + + // Asserts auction has not been extended (time remaining > 1 hour) + placeBid(ACTOR1, 20 ether); + assertEq(auction.endAt(), block.timestamp + 1 days); + + // fast forward to 30 minutes before end of auction + vm.warp(block.timestamp + 1 days - 30 minutes); + + assertEq(auction.endAt(), block.timestamp + 30 minutes); + + // Asserts auction has been extended (bid placed within 1 hour of auction end) + placeBid(ACTOR2, 30 ether); + assertEq(auction.endAt(), block.timestamp + 1 hours); + } + + function test_cannot_place_bid_auction_not_started() public { + assertFalse(auction.started()); + + // Try placing a bid + vm.startPrank(ACTOR1); + kwenta.approve(address(auction), TEST_VALUE); + + vm.expectRevert(Auction.AuctionNotStarted.selector); + auction.bid(TEST_VALUE); + vm.stopPrank(); + } + + function test_cannot_place_bid_auction_ended() public { + startAuction(AUCTION_TEST_VALUE); + + // fast forward 1 week + vm.warp(block.timestamp + 1 weeks); + + // Try placing a bid + vm.startPrank(ACTOR1); + kwenta.approve(address(auction), TEST_VALUE); + + vm.expectRevert(Auction.AuctionAlreadyEnded.selector); + auction.bid(TEST_VALUE); + vm.stopPrank(); + } + + function test_bid_event() public { + startAuction(AUCTION_TEST_VALUE); + + vm.startPrank(ACTOR1); + kwenta.approve(address(auction), TEST_VALUE); + + vm.expectEmit(true, true, true, true); + emit Bid(ACTOR1, TEST_VALUE); + auction.bid(TEST_VALUE); + vm.stopPrank(); + } + + function test_cannot_place_bid_bidding_frozen() public { + startAuction(AUCTION_TEST_VALUE); + + // Lock bidding + vm.prank(OWNER); + auction.freezeBidding(); + + // Try placing a bid + vm.startPrank(ACTOR1); + kwenta.approve(address(auction), TEST_VALUE); + + vm.expectRevert(Auction.BiddingFrozenErr.selector); + auction.bid(TEST_VALUE); + vm.stopPrank(); + + vm.prank(OWNER); + auction.resumeBidding(); + + placeBid(ACTOR1, TEST_VALUE); + } + + function test_freeze_bidding_event() public { + startAuction(AUCTION_TEST_VALUE); + + vm.prank(OWNER); + vm.expectEmit(true, true, true, true); + emit BiddingFrozen(); + auction.freezeBidding(); + } + + function test_resume_bidding_event() public { + startAuction(AUCTION_TEST_VALUE); + + vm.prank(OWNER); + vm.expectEmit(true, true, true, true); + emit BiddingResumed(); + auction.resumeBidding(); + } + + /*////////////////////////////////////////////////////////////// + withdraw + //////////////////////////////////////////////////////////////*/ + + function test_withdraw(uint256 firstBidAmount, uint256 secondBidAmount) + public + { + firstBidAmount = bound( + firstBidAmount, STARTING_BID + BID_BUFFER, TEST_VALUE - BID_BUFFER + ); + secondBidAmount = + bound(secondBidAmount, firstBidAmount + BID_BUFFER, TEST_VALUE); + + startAuction(AUCTION_TEST_VALUE); + + // Checks initial kwenta balances + assertEq(kwenta.balanceOf(ACTOR1), TEST_VALUE); + assertEq(kwenta.balanceOf(ACTOR2), TEST_VALUE); + + // Place first bid + placeBid(ACTOR1, firstBidAmount); + + // Actor has nothing to withdraw as he is the highest bidder + assertEq(kwenta.balanceOf(ACTOR1), TEST_VALUE - firstBidAmount); + assertEq(auction.highestBidder(), ACTOR1); + assertEq(auction.bids(ACTOR1), 0); + + assertEq(kwenta.balanceOf(address(auction)), firstBidAmount); + + // Place second bid + placeBid(ACTOR2, secondBidAmount); + + // Asserts ACTOR2 is now highest bidder and actor 1 can withdraw his bid + assertEq(kwenta.balanceOf(ACTOR2), TEST_VALUE - secondBidAmount); + assertEq(auction.highestBidder(), ACTOR2); + assertEq(auction.bids(ACTOR1), firstBidAmount); + assertEq(auction.bids(ACTOR2), 0); + assertEq( + kwenta.balanceOf(address(auction)), firstBidAmount + secondBidAmount + ); + + // Actor 1 withdraws his bid + vm.prank(ACTOR1); + auction.withdraw(); + + assertEq(kwenta.balanceOf(ACTOR1), TEST_VALUE); + assertEq(auction.bids(ACTOR1), 0); + assertEq(kwenta.balanceOf(address(auction)), secondBidAmount); + } + + function test_withdraw_event() public { + startAuction(AUCTION_TEST_VALUE); + + placeBid(ACTOR1, 20 ether); + placeBid(ACTOR2, 30 ether); + + vm.prank(ACTOR1); + vm.expectEmit(true, true, true, true); + emit Withdraw(ACTOR1, 20 ether); + auction.withdraw(); + } + + /*////////////////////////////////////////////////////////////// + settleAuction + //////////////////////////////////////////////////////////////*/ + + function test_settle_auction() public { + startAuction(AUCTION_TEST_VALUE); + + // Checks initial balances + assertEq(usdc.balanceOf(ACTOR1), 0); + assertEq(usdc.balanceOf(ACTOR2), 0); + assertEq(usdc.balanceOf(address(auction)), AUCTION_TEST_VALUE); + assertEq(kwenta.balanceOf(OWNER), 0); + assertEq(kwenta.balanceOf(ACTOR1), TEST_VALUE); + assertEq(kwenta.balanceOf(ACTOR2), TEST_VALUE); + + // Place bids + placeBid(ACTOR1, 20 ether); + placeBid(ACTOR2, 30 ether); + placeBid(ACTOR1, 40 ether); + + // fast forward 1 week + vm.warp(block.timestamp + 1 weeks); + + // settle auction + auction.settleAuction(); + + // Withdraw non winning bids + vm.prank(ACTOR1); + auction.withdraw(); + vm.prank(ACTOR2); + auction.withdraw(); + + // Asserts auction has been correctly settled + assertEq(kwenta.balanceOf(OWNER), 40 ether); + assertEq(kwenta.balanceOf(ACTOR1), TEST_VALUE - 40 ether); + assertEq(kwenta.balanceOf(ACTOR2), TEST_VALUE); + assertEq(kwenta.balanceOf(address(auction)), 0); + assertEq(usdc.balanceOf(ACTOR1), AUCTION_TEST_VALUE); + assertEq(usdc.balanceOf(ACTOR2), 0); + assertEq(usdc.balanceOf(address(auction)), 0); + } + + function test_settle_auction_no_bids() public { + assertEq(usdc.balanceOf(OWNER), AUCTION_TEST_VALUE); + + startAuction(AUCTION_TEST_VALUE); + + assertEq(usdc.balanceOf(OWNER), 0); + assertEq(usdc.balanceOf(address(auction)), AUCTION_TEST_VALUE); + + // fast forward 1 week + vm.warp(block.timestamp + 1 weeks); + + // settle auction + auction.settleAuction(); + + // Asserts usdc returns to owner if no bids are placed + assertEq(usdc.balanceOf(OWNER), AUCTION_TEST_VALUE); + assertEq(usdc.balanceOf(address(auction)), 0); + } + + function test_cannot_settle_unstarted_auction() public { + // Try settling an auction that has not started + vm.prank(OWNER); + vm.expectRevert(Auction.AuctionNotStarted.selector); + auction.settleAuction(); + } + + function test_cannot_settle_unfinished_auction() public { + startAuction(AUCTION_TEST_VALUE); + + // Try settling an auction that has not ended + vm.prank(OWNER); + vm.expectRevert(Auction.AuctionNotEnded.selector); + auction.settleAuction(); + } + + function test_cannot_settle_auction_twice() public { + startAuction(AUCTION_TEST_VALUE); + + // fast forward 1 week + vm.warp(block.timestamp + 1 weeks); + + // settle auction + auction.settleAuction(); + + // Try settling an auction that has already ended + vm.expectRevert(Auction.AuctionAlreadySettled.selector); + auction.settleAuction(); + } + + function test_settle_auction_event() public { + startAuction(AUCTION_TEST_VALUE); + + placeBid(ACTOR1, TEST_VALUE); + + vm.warp(block.timestamp + 1 weeks); + + vm.expectEmit(true, true, true, true); + emit End(ACTOR1, TEST_VALUE); + auction.settleAuction(); + } + + /*////////////////////////////////////////////////////////////// + HELPERS + //////////////////////////////////////////////////////////////*/ + + function startAuction(uint256 amount) public { + vm.startPrank(OWNER); + usdc.approve(address(auction), amount); + auction.start(amount); + vm.stopPrank(); + } + + function placeBid(address account, uint256 amount) public { + vm.startPrank(account); + kwenta.approve(address(auction), amount); + auction.bid(amount); + vm.stopPrank(); + } +} diff --git a/test/mocks/MockERC20.sol b/test/mocks/MockERC20.sol new file mode 100644 index 0000000..3b03607 --- /dev/null +++ b/test/mocks/MockERC20.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract MockERC20 is ERC20 { + constructor(string memory name, string memory symbol) ERC20(name, symbol) {} + + function mint(address account, uint256 amount) public { + _mint(account, amount); + } +} diff --git a/test/mocks/MockUSDC.sol b/test/mocks/MockUSDC.sol new file mode 100644 index 0000000..b412d96 --- /dev/null +++ b/test/mocks/MockUSDC.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract MockUSDC is ERC20 { + constructor() ERC20("USDC", "USDC") {} + + function mint(address _to, uint256 _amount) public { + _mint(_to, _amount); + } + + function decimals() public view virtual override returns (uint8) { + return 6; + } +} diff --git a/test/utils/ConsolidatedEvents.sol b/test/utils/ConsolidatedEvents.sol new file mode 100644 index 0000000..644f7a2 --- /dev/null +++ b/test/utils/ConsolidatedEvents.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.25; + +/// utility contract for *testing* events. consolidates all events into one contract + +contract ConsolidatedEvents { + /*////////////////////////////////////////////////////////////// + AUCTION + //////////////////////////////////////////////////////////////*/ + + event Start(); + + event Bid(address indexed sender, uint256 amount); + + event Withdraw(address indexed bidder, uint256 amount); + + event End(address winner, uint256 amount); + + event BidBufferUpdated(uint256 newBidIncrement); + + event BiddingFrozen(); + + event BiddingResumed(); + + event FundsWithdrawn( + address indexed owner, uint256 usdcAmount, uint256 kwentaAmount + ); +} diff --git a/test/utils/Constants.sol b/test/utils/Constants.sol new file mode 100644 index 0000000..97f1019 --- /dev/null +++ b/test/utils/Constants.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.25; + +/// @title Contract for defining constants used in testing +contract Constants { + /*////////////////////////////////////////////////////////////// + AUCTION CONSTANTS + //////////////////////////////////////////////////////////////*/ + + uint256 internal constant AUCTION_TEST_VALUE = 1000e6; + + uint256 internal constant TEST_VALUE = 100 ether; + + uint256 internal constant STARTING_BID = 10 ether; + + uint256 internal constant BID_BUFFER = 1 ether; + + address internal constant OWNER = address(0x01); + + address internal constant ACTOR1 = address(0xa1); + + address internal constant ACTOR2 = address(0xa2); +}