From 6ce8d29b1e107a89754dd9f17337582734989b4d Mon Sep 17 00:00:00 2001 From: FP <83050944+fp-crypto@users.noreply.github.com> Date: Mon, 6 May 2024 13:57:36 -0700 Subject: [PATCH] feat: BaseAuctioneer (#45) * feat: auctioneer swapper * fix: receiver check * chore: remove receiver * chore: constructor * chore: rename * chore: storage layout * chore: interface * chore: prettier * chore: test BaseAuctioneer * chore: oops * chore: prettier * fix: comment * feat: prefix BaseAuctioneer members with auction * feat: allow cooldown == auction length --- src/Auctions/Auction.sol | 2 +- src/Bases/Auctioneer/BaseAuctioneer.sol | 619 +++++++++++++++++++++++ src/Bases/Auctioneer/IBaseAuctioneer.sol | 97 ++++ src/interfaces/Solidly/ISolidly.sol | 8 +- src/test/BaseAuctioneer.t.sol | 455 +++++++++++++++++ src/test/mocks/MockAuctioneer.sol | 95 ++++ 6 files changed, 1271 insertions(+), 5 deletions(-) create mode 100644 src/Bases/Auctioneer/BaseAuctioneer.sol create mode 100644 src/Bases/Auctioneer/IBaseAuctioneer.sol create mode 100644 src/test/BaseAuctioneer.t.sol create mode 100644 src/test/mocks/MockAuctioneer.sol diff --git a/src/Auctions/Auction.sol b/src/Auctions/Auction.sol index dabc85b..df883d9 100644 --- a/src/Auctions/Auction.sol +++ b/src/Auctions/Auction.sol @@ -134,7 +134,7 @@ contract Auction is Governance, ReentrancyGuard { require(auctionLength == 0, "initialized"); require(_want != address(0), "ZERO ADDRESS"); require(_auctionLength != 0, "length"); - require(_auctionLength < _auctionCooldown, "cooldown"); + require(_auctionLength <= _auctionCooldown, "cooldown"); require(_startingPrice != 0, "starting price"); // Cannot have more than 18 decimals. diff --git a/src/Bases/Auctioneer/BaseAuctioneer.sol b/src/Bases/Auctioneer/BaseAuctioneer.sol new file mode 100644 index 0000000..78a3305 --- /dev/null +++ b/src/Bases/Auctioneer/BaseAuctioneer.sol @@ -0,0 +1,619 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity >=0.8.18; + +import {Maths} from "../../libraries/Maths.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol"; +import {ITaker} from "../../interfaces/ITaker.sol"; +import {BaseHealthCheck} from "../HealthCheck/BaseHealthCheck.sol"; + +/** + * @title Base Auctioneer + * @author yearn.fi + * @notice General use dutch auction contract for token sales. + */ +abstract contract BaseAuctioneer is BaseHealthCheck, ReentrancyGuard { + using SafeERC20 for ERC20; + + /// @notice Emitted when a new auction is enabled + event AuctionEnabled( + bytes32 auctionId, + address indexed from, + address indexed to, + address indexed auctionAddress + ); + + /// @notice Emitted when an auction is disabled. + event AuctionDisabled( + bytes32 auctionId, + address indexed from, + address indexed to, + address indexed auctionAddress + ); + + /// @notice Emitted when auction has been kicked. + event AuctionKicked(bytes32 auctionId, uint256 available); + + /// @notice Emitted when any amount of an active auction was taken. + event AuctionTaken( + bytes32 auctionId, + uint256 amountTaken, + uint256 amountLeft + ); + + /// @dev Store address and scaler in one slot. + struct TokenInfo { + address tokenAddress; + uint96 scaler; + } + + /// @notice Store all the auction specific information. + struct AuctionInfo { + TokenInfo fromInfo; + uint96 kicked; + uint128 initialAvailable; + uint128 currentAvailable; + } + + uint256 internal constant WAD = 1e18; + + /// @notice Used for the price decay. + uint256 internal constant MINUTE_HALF_LIFE = + 0.988514020352896135_356867505 * 1e27; // 0.5^(1/60) + + /// @notice Struct to hold the info for `auctionWant`. + TokenInfo internal auctionWantInfo; + + /// @notice Mapping from an auction ID to its struct. + mapping(bytes32 => AuctionInfo) public auctions; + + /// @notice Array of all the enabled auction for this contract. + bytes32[] public enabledAuctions; + + /// @notice The amount to start the auction at. + uint256 public auctionStartingPrice; + + /// @notice The time that each auction lasts. + uint32 public auctionLength; + + /// @notice The minimum time to wait between auction 'kicks'. + uint32 public auctionCooldown; + + /** + * @notice Initializes the Auction contract with initial parameters. + * @param _auctionWant Address this auction is selling to. + * @param _auctionLength Duration of each auction in seconds. + * @param _auctionCooldown Cooldown period between auctions in seconds. + * @param _auctionStartingPrice Starting price for each auction. + */ + constructor( + address _asset, + string memory _name, + address _auctionWant, + uint32 _auctionLength, + uint32 _auctionCooldown, + uint256 _auctionStartingPrice + ) BaseHealthCheck(_asset, _name) { + require(auctionLength == 0, "initialized"); + require(_auctionWant != address(0), "ZERO ADDRESS"); + require(_auctionLength != 0, "length"); + require(_auctionLength <= _auctionCooldown, "cooldown"); + require(_auctionStartingPrice != 0, "starting price"); + + // Cannot have more than 18 decimals. + uint256 decimals = ERC20(_auctionWant).decimals(); + require(decimals <= 18, "unsupported decimals"); + + // Set variables + auctionWantInfo = TokenInfo({ + tokenAddress: _auctionWant, + scaler: uint96(WAD / 10 ** decimals) + }); + + auctionLength = _auctionLength; + auctionCooldown = _auctionCooldown; + auctionStartingPrice = _auctionStartingPrice; + } + + /*////////////////////////////////////////////////////////////// + VIEW METHODS + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Get the address of this auctions want token. + * @return . The want token. + */ + function auctionWant() public view virtual returns (address) { + return auctionWantInfo.tokenAddress; + } + + /** + * @notice Get the length of the enabled auctions array. + */ + function numberOfEnabledAuctions() external view virtual returns (uint256) { + return enabledAuctions.length; + } + + /** + * @notice Get the unique auction identifier. + * @param _from The address of the token to sell. + * @return bytes32 A unique auction identifier. + */ + function getAuctionId(address _from) public view virtual returns (bytes32) { + return keccak256(abi.encodePacked(_from, auctionWant(), address(this))); + } + + /** + * @notice Retrieves information about a specific auction. + * @param _auctionId The unique identifier of the auction. + * @return _from The address of the token to sell. + * @return _to The address of the token to buy. + * @return _kicked The timestamp of the last kick. + * @return _available The current available amount for the auction. + */ + function auctionInfo( + bytes32 _auctionId + ) + public + view + virtual + returns ( + address _from, + address _to, + uint256 _kicked, + uint256 _available + ) + { + AuctionInfo memory auction = auctions[_auctionId]; + + return ( + auction.fromInfo.tokenAddress, + auctionWant(), + auction.kicked, + auction.kicked + uint256(auctionLength) > block.timestamp + ? auction.currentAvailable + : 0 + ); + } + + /** + * @notice Get the pending amount available for the next auction. + * @dev Defaults to the auctions balance of the from token if no hook. + * @param _auctionId The unique identifier of the auction. + * @return uint256 The amount that can be kicked into the auction. + */ + function kickable( + bytes32 _auctionId + ) public view virtual returns (uint256) { + // If not enough time has passed then `kickable` is 0. + if ( + auctions[_auctionId].kicked + uint256(auctionCooldown) > + block.timestamp + ) { + return 0; + } + + return _kickable(auctions[_auctionId].fromInfo.tokenAddress); + } + + /** + * @notice Gets the amount of `auctionWant` needed to buy a specific amount of `from`. + * @param _auctionId The unique identifier of the auction. + * @param _amountToTake The amount of `from` to take in the auction. + * @return . The amount of `auctionWant` needed to fulfill the take amount. + */ + function getAmountNeeded( + bytes32 _auctionId, + uint256 _amountToTake + ) external view virtual returns (uint256) { + return + _getAmountNeeded( + auctions[_auctionId], + _amountToTake, + block.timestamp + ); + } + + /** + * @notice Gets the amount of `auctionWant` needed to buy a specific amount of `from` at a specific timestamp. + * @param _auctionId The unique identifier of the auction. + * @param _amountToTake The amount `from` to take in the auction. + * @param _timestamp The specific timestamp for calculating the amount needed. + * @return . The amount of `auctionWant` needed to fulfill the take amount. + */ + function getAmountNeeded( + bytes32 _auctionId, + uint256 _amountToTake, + uint256 _timestamp + ) external view virtual returns (uint256) { + return + _getAmountNeeded(auctions[_auctionId], _amountToTake, _timestamp); + } + + /** + * @dev Return the amount of `auctionWant` needed to buy `_amountToTake`. + */ + function _getAmountNeeded( + AuctionInfo memory _auction, + uint256 _amountToTake, + uint256 _timestamp + ) internal view virtual returns (uint256) { + return + // Scale _amountToTake to 1e18 + (_amountToTake * + _auction.fromInfo.scaler * + // Price is always 1e18 + _price( + _auction.kicked, + _auction.initialAvailable * _auction.fromInfo.scaler, + _timestamp + )) / + 1e18 / + // Scale back down to auctionWant. + auctionWantInfo.scaler; + } + + /** + * @notice Gets the price of the auction at the current timestamp. + * @param _auctionId The unique identifier of the auction. + * @return . The price of the auction. + */ + function price(bytes32 _auctionId) external view virtual returns (uint256) { + return price(_auctionId, block.timestamp); + } + + /** + * @notice Gets the price of the auction at a specific timestamp. + * @param _auctionId The unique identifier of the auction. + * @param _timestamp The specific timestamp for calculating the price. + * @return . The price of the auction. + */ + function price( + bytes32 _auctionId, + uint256 _timestamp + ) public view virtual returns (uint256) { + // Get unscaled price and scale it down. + return + _price( + auctions[_auctionId].kicked, + auctions[_auctionId].initialAvailable * + auctions[_auctionId].fromInfo.scaler, + _timestamp + ) / auctionWantInfo.scaler; + } + + /** + * @dev Internal function to calculate the scaled price based on auction parameters. + * @param _kicked The timestamp the auction was kicked. + * @param _available The initial available amount scaled 1e18. + * @param _timestamp The specific timestamp for calculating the price. + * @return . The calculated price scaled to 1e18. + */ + function _price( + uint256 _kicked, + uint256 _available, + uint256 _timestamp + ) internal view virtual returns (uint256) { + if (_available == 0) return 0; + + uint256 secondsElapsed = _timestamp - _kicked; + + if (secondsElapsed > auctionLength) return 0; + + // Exponential decay from https://github.com/ajna-finance/ajna-core/blob/master/src/libraries/helpers/PoolHelper.sol + uint256 hoursComponent = 1e27 >> (secondsElapsed / 3600); + uint256 minutesComponent = Maths.rpow( + MINUTE_HALF_LIFE, + (secondsElapsed % 3600) / 60 + ); + uint256 initialPrice = Maths.wdiv( + auctionStartingPrice * 1e18, + _available + ); + + return + (initialPrice * Maths.rmul(hoursComponent, minutesComponent)) / + 1e27; + } + + /*////////////////////////////////////////////////////////////// + SETTERS + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Enables a new auction. + * @param _from The address of the token to be auctioned. + * @return _auctionId The unique identifier of the enabled auction. + */ + function enableAuction( + address _from + ) public virtual onlyManagement returns (bytes32 _auctionId) { + address _auctionWant = auctionWant(); + require(_from != address(0) && _from != _auctionWant, "ZERO ADDRESS"); + // Cannot have more than 18 decimals. + uint256 decimals = ERC20(_from).decimals(); + require(decimals <= 18, "unsupported decimals"); + + // Calculate the id. + _auctionId = getAuctionId(_from); + + require( + auctions[_auctionId].fromInfo.tokenAddress == address(0), + "already enabled" + ); + + // Store all needed info. + auctions[_auctionId].fromInfo = TokenInfo({ + tokenAddress: _from, + scaler: uint96(WAD / 10 ** decimals) + }); + + // Add to the array. + enabledAuctions.push(_auctionId); + + emit AuctionEnabled(_auctionId, _from, _auctionWant, address(this)); + } + + /** + * @notice Disables an existing auction. + * @dev Only callable by governance. + * @param _from The address of the token being sold. + */ + function disableAuction(address _from) external virtual { + disableAuction(_from, 0); + } + + /** + * @notice Disables an existing auction. + * @dev Only callable by governance. + * @param _from The address of the token being sold. + * @param _index The index the auctionId is at in the array. + */ + function disableAuction( + address _from, + uint256 _index + ) public virtual onlyEmergencyAuthorized { + bytes32 _auctionId = getAuctionId(_from); + + // Make sure the auction was enabled. + require( + auctions[_auctionId].fromInfo.tokenAddress != address(0), + "not enabled" + ); + + // Remove the struct. + delete auctions[_auctionId]; + + // Remove the auction ID from the array. + bytes32[] memory _enabledAuctions = enabledAuctions; + if (_enabledAuctions[_index] != _auctionId) { + // If the _index given is not the id find it. + for (uint256 i = 0; i < _enabledAuctions.length; ++i) { + if (_enabledAuctions[i] == _auctionId) { + _index = i; + break; + } + } + } + + // Move the id to the last spot if not there. + if (_index < _enabledAuctions.length - 1) { + _enabledAuctions[_index] = _enabledAuctions[ + _enabledAuctions.length - 1 + ]; + // Update the array. + enabledAuctions = _enabledAuctions; + } + + // Pop the id off the array. + enabledAuctions.pop(); + + emit AuctionDisabled(_auctionId, _from, auctionWant(), address(this)); + } + + /*////////////////////////////////////////////////////////////// + PARTICIPATE IN AUCTION + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Kicks off an auction, updating its status and making funds available for bidding. + * @param _auctionId The unique identifier of the auction. + * @return available The available amount for bidding on in the auction. + */ + function kick( + bytes32 _auctionId + ) external virtual nonReentrant returns (uint256 available) { + address _fromToken = auctions[_auctionId].fromInfo.tokenAddress; + require(_fromToken != address(0), "not enabled"); + require( + block.timestamp > + auctions[_auctionId].kicked + uint256(auctionCooldown), + "too soon" + ); + + available = _auctionKicked(_fromToken); + + require(available != 0, "nothing to kick"); + + // Update the auctions status. + auctions[_auctionId].kicked = uint96(block.timestamp); + auctions[_auctionId].initialAvailable = uint128(available); + auctions[_auctionId].currentAvailable = uint128(available); + + emit AuctionKicked(_auctionId, available); + } + + /** + * @notice Take the token being sold in a live auction. + * @dev Defaults to taking the full amount and sending to the msg sender. + * @param _auctionId The unique identifier of the auction. + * @return . The amount of fromToken taken in the auction. + */ + function take(bytes32 _auctionId) external virtual returns (uint256) { + return _take(_auctionId, type(uint256).max, msg.sender, new bytes(0)); + } + + /** + * @notice Take the token being sold in a live auction with a specified maximum amount. + * @dev Uses the sender's address as the receiver. + * @param _auctionId The unique identifier of the auction. + * @param _maxAmount The maximum amount of fromToken to take in the auction. + * @return . The amount of fromToken taken in the auction. + */ + function take( + bytes32 _auctionId, + uint256 _maxAmount + ) external virtual returns (uint256) { + return _take(_auctionId, _maxAmount, msg.sender, new bytes(0)); + } + + /** + * @notice Take the token being sold in a live auction. + * @param _auctionId The unique identifier of the auction. + * @param _maxAmount The maximum amount of fromToken to take in the auction. + * @param _receiver The address that will receive the fromToken. + * @return _amountTaken The amount of fromToken taken in the auction. + */ + function take( + bytes32 _auctionId, + uint256 _maxAmount, + address _receiver + ) external virtual returns (uint256) { + return _take(_auctionId, _maxAmount, _receiver, new bytes(0)); + } + + /** + * @notice Take the token being sold in a live auction. + * @param _auctionId The unique identifier of the auction. + * @param _maxAmount The maximum amount of fromToken to take in the auction. + * @param _receiver The address that will receive the fromToken. + * @param _data The data signify the callback should be used and sent with it. + * @return _amountTaken The amount of fromToken taken in the auction. + */ + function take( + bytes32 _auctionId, + uint256 _maxAmount, + address _receiver, + bytes calldata _data + ) external virtual returns (uint256) { + return _take(_auctionId, _maxAmount, _receiver, _data); + } + + /// @dev Implements the take of the auction. + function _take( + bytes32 _auctionId, + uint256 _maxAmount, + address _receiver, + bytes memory _data + ) internal virtual nonReentrant returns (uint256 _amountTaken) { + AuctionInfo memory auction = auctions[_auctionId]; + // Make sure the auction is active. + require( + auction.kicked + uint256(auctionLength) >= block.timestamp, + "not kicked" + ); + + // Max amount that can be taken. + _amountTaken = auction.currentAvailable > _maxAmount + ? _maxAmount + : auction.currentAvailable; + + // Get the amount needed + uint256 needed = _getAmountNeeded( + auction, + _amountTaken, + block.timestamp + ); + + require(needed != 0, "zero needed"); + + // How much is left in this auction. + uint256 left; + unchecked { + left = auction.currentAvailable - _amountTaken; + } + auctions[_auctionId].currentAvailable = uint128(left); + + _preTake(auction.fromInfo.tokenAddress, _amountTaken, needed); + + // Send `from`. + ERC20(auction.fromInfo.tokenAddress).safeTransfer( + _receiver, + _amountTaken + ); + + // If the caller has specified data. + if (_data.length != 0) { + // Do the callback. + ITaker(_receiver).auctionTakeCallback( + _auctionId, + msg.sender, + _amountTaken, + needed, + _data + ); + } + + // Cache the auctionWant address. + address _auctionWant = auctionWant(); + + // Pull `auctionWant`. + ERC20(_auctionWant).safeTransferFrom(msg.sender, address(this), needed); + + _postTake(_auctionWant, _amountTaken, needed); + + emit AuctionTaken(_auctionId, _amountTaken, left); + } + + /** + * @notice Return how much `_token` could currently be kicked into auction. + * @dev This can be overridden by a strategist to implement custom logic. + * @param _token Address of the `_from` token. + * @return . The amount of `_token` ready to be auctioned off. + */ + function _kickable(address _token) internal view virtual returns (uint256) { + return ERC20(_token).balanceOf(address(this)); + } + + /** + * @dev To override if something other than just sending the loose balance + * of `_token` to the auction is desired, such as accruing and and claiming rewards. + * + * @param _token Address of the token being auctioned off + */ + function _auctionKicked(address _token) internal virtual returns (uint256) { + return ERC20(_token).balanceOf(address(this)); + } + + /** + * @dev To override if something needs to be done before a take is completed. + * This can be used if the auctioned token only will be freed up when a `take` + * occurs. + * @param _token Address of the token being taken. + * @param _amountToTake Amount of `_token` needed. + * @param _amountToPay Amount of `auctionWant` that will be payed. + */ + function _preTake( + address _token, + uint256 _amountToTake, + uint256 _amountToPay + ) internal virtual {} + + /** + * @dev To override if a post take action is desired. + * + * This could be used to re-deploy the bought token back into the yield source, + * or in conjunction with {_preTake} to check that the price sold at was within + * some allowed range. + * + * @param _token Address of the token that the strategy was sent. + * @param _amountTaken Amount of the from token taken. + * @param _amountPayed Amount of `_token` that was sent to the strategy. + */ + function _postTake( + address _token, + uint256 _amountTaken, + uint256 _amountPayed + ) internal virtual {} +} diff --git a/src/Bases/Auctioneer/IBaseAuctioneer.sol b/src/Bases/Auctioneer/IBaseAuctioneer.sol new file mode 100644 index 0000000..6b9a858 --- /dev/null +++ b/src/Bases/Auctioneer/IBaseAuctioneer.sol @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity >=0.8.18; + +import {IBaseHealthCheck} from "../HealthCheck/IBaseHealthCheck.sol"; + +interface IBaseAuctioneer is IBaseHealthCheck { + struct TokenInfo { + address tokenAddress; + uint96 scaler; + } + + function auctionStartingPrice() external view returns (uint256); + + function auctionLength() external view returns (uint32); + + function auctionCooldown() external view returns (uint32); + + function auctions( + bytes32 + ) + external + view + returns ( + TokenInfo memory fromInfo, + uint96 kicked, + uint128 initialAvailable, + uint128 currentAvailable + ); + + function enabledAuctions() external view returns (bytes32[] memory); + + function auctionWant() external view returns (address); + + function numberOfEnabledAuctions() external view returns (uint256); + + function getAuctionId(address _from) external view returns (bytes32); + + function auctionInfo( + bytes32 _auctionId + ) + external + view + returns ( + address _from, + address _to, + uint256 _kicked, + uint256 _available + ); + + function kickable(bytes32 _auctionId) external view returns (uint256); + + function getAmountNeeded( + bytes32 _auctionId, + uint256 _amountToTake + ) external view returns (uint256); + + function getAmountNeeded( + bytes32 _auctionId, + uint256 _amountToTake, + uint256 _timestamp + ) external view returns (uint256); + + function price(bytes32 _auctionId) external view returns (uint256); + + function price( + bytes32 _auctionId, + uint256 _timestamp + ) external view returns (uint256); + + function enableAuction(address _from) external returns (bytes32); + + function disableAuction(address _from) external; + + function disableAuction(address _from, uint256 _index) external; + + function kick(bytes32 _auctionId) external returns (uint256 available); + + function take(bytes32 _auctionId) external returns (uint256); + + function take( + bytes32 _auctionId, + uint256 _maxAmount + ) external returns (uint256); + + function take( + bytes32 _auctionId, + uint256 _maxAmount, + address _receiver + ) external returns (uint256); + + function take( + bytes32 _auctionId, + uint256 _maxAmount, + address _receiver, + bytes calldata _data + ) external returns (uint256); +} diff --git a/src/interfaces/Solidly/ISolidly.sol b/src/interfaces/Solidly/ISolidly.sol index b21e4f4..54aede6 100644 --- a/src/interfaces/Solidly/ISolidly.sol +++ b/src/interfaces/Solidly/ISolidly.sol @@ -9,15 +9,15 @@ interface ISolidly { } function swapExactTokensForTokens( - uint amountIn, - uint amountOutMin, + uint256 amountIn, + uint256 amountOutMin, route[] calldata routes, address to, - uint deadline + uint256 deadline ) external returns (uint256[] memory amounts); function getAmountsOut( - uint amountIn, + uint256 amountIn, route[] memory routes ) external view returns (uint256[] memory amounts); } diff --git a/src/test/BaseAuctioneer.t.sol b/src/test/BaseAuctioneer.t.sol new file mode 100644 index 0000000..c689418 --- /dev/null +++ b/src/test/BaseAuctioneer.t.sol @@ -0,0 +1,455 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity >=0.8.18; + +import "forge-std/console.sol"; +import {Setup, IStrategy, SafeERC20, ERC20} from "./utils/Setup.sol"; + +import {IMockAuctioneer, MockAuctioneer} from "./mocks/MockAuctioneer.sol"; + +contract BaseAuctioneerTest is Setup { + using SafeERC20 for ERC20; + + event PreTake(address token, uint256 amountToTake, uint256 amountToPay); + event PostTake(address token, uint256 amountTaken, uint256 amountPayed); + + event DeployedNewAuction(address indexed auction, address indexed want); + + event AuctionEnabled( + bytes32 auctionId, + address indexed from, + address indexed to, + address indexed strategy + ); + + event AuctionDisabled( + bytes32 auctionId, + address indexed from, + address indexed to, + address indexed strategy + ); + + event AuctionKicked(bytes32 auctionId, uint256 available); + + event AuctionTaken( + bytes32 auctionId, + uint256 amountTaken, + uint256 amountLeft + ); + + IMockAuctioneer public auctioneer; + + uint256 public wantScaler; + uint256 public fromScaler; + + function setUp() public override { + super.setUp(); + + auctioneer = IMockAuctioneer( + address(new MockAuctioneer(address(asset))) + ); + + vm.label(address(auctioneer), "Auctioneer"); + } + + function test_enableAuction() public { + address from = tokenAddrs["USDC"]; + + bytes32 id = auctioneer.enableAuction(from); + + assertEq(auctioneer.kickable(id), 0); + assertEq(auctioneer.getAmountNeeded(id, 1e18), 0); + assertEq(auctioneer.price(id), 0); + ( + address _from, + address _to, + uint256 _kicked, + uint256 _available + ) = auctioneer.auctionInfo(id); + + assertEq(_from, from); + assertEq(_to, address(asset)); + assertEq(_kicked, 0); + assertEq(_available, 0); + + // Kicking it reverts + vm.expectRevert("nothing to kick"); + auctioneer.kick(id); + + // Can't re-enable + vm.expectRevert("already enabled"); + auctioneer.enableAuction(from); + } + + function test_enableSecondAuction() public { + address from = tokenAddrs["USDC"]; + + bytes32 id = auctioneer.enableAuction(from); + + assertEq(auctioneer.kickable(id), 0); + assertEq(auctioneer.getAmountNeeded(id, 1e18), 0); + assertEq(auctioneer.price(id), 0); + ( + address _from, + address _to, + uint256 _kicked, + uint256 _available + ) = auctioneer.auctionInfo(id); + + assertEq(_from, from); + assertEq(_to, address(asset)); + assertEq(_kicked, 0); + assertEq(_available, 0); + + address secondFrom = tokenAddrs["WETH"]; + + bytes32 expectedId = auctioneer.getAuctionId(secondFrom); + + vm.expectEmit(true, true, true, true, address(auctioneer)); + emit AuctionEnabled( + expectedId, + secondFrom, + address(asset), + address(auctioneer) + ); + bytes32 secondId = auctioneer.enableAuction(secondFrom); + + assertEq(expectedId, secondId); + assertEq(auctioneer.kickable(secondId), 0); + assertEq(auctioneer.getAmountNeeded(secondId, 1e18), 0); + assertEq(auctioneer.price(secondId), 0); + (_from, _to, _kicked, _available) = auctioneer.auctionInfo(secondId); + + assertEq(_from, secondFrom); + assertEq(_to, address(asset)); + assertEq(_kicked, 0); + assertEq(_available, 0); + } + + function test_disableAuction() public { + address from = tokenAddrs["USDC"]; + + bytes32 id = auctioneer.enableAuction(from); + + assertEq(auctioneer.kickable(id), 0); + assertEq(auctioneer.getAmountNeeded(id, 1e18), 0); + assertEq(auctioneer.price(id), 0); + ( + address _from, + address _to, + uint256 _kicked, + uint256 _available + ) = auctioneer.auctionInfo(id); + + assertEq(_from, from); + assertEq(_to, address(asset)); + assertEq(_kicked, 0); + assertEq(_available, 0); + + vm.expectEmit(true, true, true, true, address(auctioneer)); + emit AuctionDisabled(id, from, address(asset), address(auctioneer)); + auctioneer.disableAuction(from); + + (_from, _to, _kicked, _available) = auctioneer.auctionInfo(id); + + assertEq(_from, address(0)); + assertEq(_to, address(asset)); + assertEq(_kicked, 0); + assertEq(_available, 0); + } + + function test_kickAuction_default(uint256 _amount) public { + vm.assume(_amount >= minFuzzAmount && _amount <= maxFuzzAmount); + + address from = tokenAddrs["WBTC"]; + + fromScaler = WAD / 10 ** ERC20(from).decimals(); + wantScaler = WAD / 10 ** ERC20(asset).decimals(); + + bytes32 id = auctioneer.enableAuction(from); + + assertEq(auctioneer.kickable(id), 0); + ( + address _from, + address _to, + uint256 _kicked, + uint256 _available + ) = auctioneer.auctionInfo(id); + + assertEq(_from, from); + assertEq(_to, address(asset)); + assertEq(_kicked, 0); + assertEq(_available, 0); + + airdrop(ERC20(from), address(auctioneer), _amount); + + assertEq(auctioneer.kickable(id), _amount); + (, , _kicked, _available) = auctioneer.auctionInfo(id); + assertEq(_kicked, 0); + assertEq(_available, 0); + + vm.expectEmit(true, true, true, true, address(auctioneer)); + emit AuctionKicked(id, _amount); + uint256 available = auctioneer.kick(id); + + assertEq(ERC20(from).balanceOf(address(auctioneer)), _amount); + assertEq(ERC20(from).balanceOf(address(auctioneer)), available); + + assertEq(auctioneer.kickable(id), 0); + (, , _kicked, _available) = auctioneer.auctionInfo(id); + assertEq(_kicked, block.timestamp); + assertEq(_available, _amount); + uint256 startingPrice = ((auctioneer.auctionStartingPrice() * + (WAD / wantScaler)) * 1e18) / + _amount / + fromScaler; + assertEq(auctioneer.price(id), startingPrice); + assertRelApproxEq( + auctioneer.getAmountNeeded(id, _amount), + (startingPrice * fromScaler * _amount) / + (WAD / wantScaler) / + wantScaler, + MAX_BPS + ); + + uint256 expectedPrice = auctioneer.price(id, block.timestamp + 100); + assertLt(expectedPrice, startingPrice); + uint256 expectedAmount = auctioneer.getAmountNeeded( + id, + _amount, + block.timestamp + 100 + ); + assertLt( + expectedAmount, + (startingPrice * fromScaler * _amount) / + (WAD / wantScaler) / + wantScaler + ); + + skip(100); + + assertEq(auctioneer.price(id), expectedPrice); + assertEq(auctioneer.getAmountNeeded(id, _amount), expectedAmount); + + // Skip full auction + skip(auctioneer.auctionLength()); + + assertEq(auctioneer.price(id), 0); + assertEq(auctioneer.getAmountNeeded(id, _amount), 0); + + // Can't kick a new one yet + vm.expectRevert("too soon"); + auctioneer.kick(id); + + assertEq(auctioneer.kickable(id), 0); + } + + function test_takeAuction_default(uint256 _amount, uint16 _percent) public { + vm.assume(_amount >= minFuzzAmount && _amount <= maxFuzzAmount); + _percent = uint16(bound(uint256(_percent), 1_000, MAX_BPS)); + + address from = tokenAddrs["WBTC"]; + + fromScaler = WAD / 10 ** ERC20(from).decimals(); + wantScaler = WAD / 10 ** ERC20(asset).decimals(); + + bytes32 id = auctioneer.enableAuction(from); + + airdrop(ERC20(from), address(auctioneer), _amount); + + auctioneer.kick(id); + + assertEq(auctioneer.kickable(id), 0); + ( + address _from, + address _to, + uint256 _kicked, + uint256 _available + ) = auctioneer.auctionInfo(id); + assertEq(_from, from); + assertEq(_to, address(asset)); + assertEq(_kicked, block.timestamp); + assertEq(_available, _amount); + assertEq(ERC20(from).balanceOf(address(auctioneer)), _amount); + + skip(auctioneer.auctionLength() / 2); + + uint256 toTake = (_amount * _percent) / MAX_BPS; + uint256 left = _amount - toTake; + uint256 needed = auctioneer.getAmountNeeded(id, toTake); + + airdrop(ERC20(asset), address(this), needed); + + ERC20(asset).safeApprove(address(auctioneer), needed); + + uint256 before = ERC20(from).balanceOf(address(this)); + + vm.expectEmit(true, true, true, true, address(auctioneer)); + emit AuctionTaken(id, toTake, left); + uint256 amountTaken = auctioneer.take(id, toTake); + + assertEq(amountTaken, toTake); + + (, , , _available) = auctioneer.auctionInfo(id); + assertEq(_available, left); + assertEq(ERC20(asset).balanceOf(address(this)), 0); + assertEq(ERC20(from).balanceOf(address(this)), before + toTake); + assertEq(ERC20(from).balanceOf(address(auctioneer)), left); + assertEq(ERC20(asset).balanceOf(address(auctioneer)), needed); + } + + function test_kickAuction_custom(uint256 _amount) public { + vm.assume(_amount >= minFuzzAmount && _amount <= maxFuzzAmount); + + address from = tokenAddrs["WBTC"]; + + fromScaler = WAD / 10 ** ERC20(from).decimals(); + wantScaler = WAD / 10 ** ERC20(asset).decimals(); + + bytes32 id = auctioneer.enableAuction(from); + + assertEq(auctioneer.kickable(id), 0); + ( + address _from, + address _to, + uint256 _kicked, + uint256 _available + ) = auctioneer.auctionInfo(id); + + assertEq(_from, from); + assertEq(_to, address(asset)); + assertEq(_kicked, 0); + assertEq(_available, 0); + + airdrop(ERC20(from), address(auctioneer), _amount); + + auctioneer.setUseDefault(false); + + assertEq(auctioneer.kickable(id), 0); + + uint256 kickable = _amount / 10; + auctioneer.setLetKick(kickable); + + assertEq(auctioneer.kickable(id), kickable); + (, , _kicked, _available) = auctioneer.auctionInfo(id); + assertEq(_kicked, 0); + assertEq(_available, 0); + + vm.expectEmit(true, true, true, true, address(auctioneer)); + emit AuctionKicked(id, kickable); + uint256 available = auctioneer.kick(id); + + assertEq(ERC20(from).balanceOf(address(auctioneer)), _amount); + assertEq(kickable, available); + + assertEq(auctioneer.kickable(id), 0); + (, , _kicked, _available) = auctioneer.auctionInfo(id); + assertEq(_kicked, block.timestamp); + assertEq(_available, kickable); + uint256 startingPrice = ((auctioneer.auctionStartingPrice() * + (WAD / wantScaler)) * 1e18) / + kickable / + fromScaler; + assertEq(auctioneer.price(id), startingPrice); + assertRelApproxEq( + auctioneer.getAmountNeeded(id, kickable), + (startingPrice * fromScaler * kickable) / + (WAD / wantScaler) / + wantScaler, + MAX_BPS + ); + + uint256 expectedPrice = auctioneer.price(id, block.timestamp + 100); + assertLt(expectedPrice, startingPrice); + uint256 expectedAmount = auctioneer.getAmountNeeded( + id, + kickable, + block.timestamp + 100 + ); + assertLt( + expectedAmount, + (startingPrice * fromScaler * kickable) / + (WAD / wantScaler) / + wantScaler + ); + + skip(100); + + assertEq(auctioneer.price(id), expectedPrice); + assertEq(auctioneer.getAmountNeeded(id, kickable), expectedAmount); + + // Skip full auction + skip(auctioneer.auctionLength()); + + assertEq(auctioneer.price(id), 0); + assertEq(auctioneer.getAmountNeeded(id, kickable), 0); + + // Can't kick a new one yet + vm.expectRevert("too soon"); + auctioneer.kick(id); + + assertEq(auctioneer.kickable(id), 0); + } + + function test_takeAuction_custom(uint256 _amount, uint16 _percent) public { + vm.assume(_amount >= minFuzzAmount && _amount <= maxFuzzAmount); + _percent = uint16(bound(uint256(_percent), 1_000, MAX_BPS)); + + address from = tokenAddrs["WBTC"]; + + fromScaler = WAD / 10 ** ERC20(from).decimals(); + wantScaler = WAD / 10 ** ERC20(asset).decimals(); + + bytes32 id = auctioneer.enableAuction(from); + + airdrop(ERC20(from), address(auctioneer), _amount); + + auctioneer.setUseDefault(false); + + assertEq(auctioneer.kickable(id), 0); + + uint256 kickable = _amount / 10; + auctioneer.setLetKick(kickable); + + auctioneer.kick(id); + + assertEq(auctioneer.kickable(id), 0); + ( + address _from, + address _to, + uint256 _kicked, + uint256 _available + ) = auctioneer.auctionInfo(id); + assertEq(_from, from); + assertEq(_to, address(asset)); + assertEq(_kicked, block.timestamp); + assertEq(_available, kickable); + assertEq(ERC20(from).balanceOf(address(auctioneer)), _amount); + + skip(auctioneer.auctionLength() / 2); + + uint256 toTake = (kickable * _percent) / MAX_BPS; + uint256 left = _amount - toTake; + uint256 needed = auctioneer.getAmountNeeded(id, toTake); + + airdrop(ERC20(asset), address(this), needed); + + ERC20(asset).safeApprove(address(auctioneer), needed); + + uint256 before = ERC20(from).balanceOf(address(this)); + + vm.expectEmit(true, true, true, true, address(auctioneer)); + emit PreTake(from, toTake, needed); + vm.expectEmit(true, true, true, true, address(auctioneer)); + emit PostTake(address(asset), toTake, needed); + uint256 amountTaken = auctioneer.take(id, toTake); + + assertEq(amountTaken, toTake); + + (, , , _available) = auctioneer.auctionInfo(id); + assertEq(_available, kickable - toTake); + assertEq(ERC20(asset).balanceOf(address(this)), 0); + assertEq(ERC20(from).balanceOf(address(this)), before + toTake); + assertEq(ERC20(from).balanceOf(address(auctioneer)), left); + assertEq(ERC20(asset).balanceOf(address(auctioneer)), needed); + } +} diff --git a/src/test/mocks/MockAuctioneer.sol b/src/test/mocks/MockAuctioneer.sol new file mode 100644 index 0000000..648f964 --- /dev/null +++ b/src/test/mocks/MockAuctioneer.sol @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.18; + +import {BaseAuctioneer, SafeERC20} from "../../Bases/Auctioneer/BaseAuctioneer.sol"; +import {BaseStrategy, ERC20} from "@tokenized-strategy/BaseStrategy.sol"; + +contract MockAuctioneer is BaseAuctioneer { + using SafeERC20 for ERC20; + + event PreTake(address token, uint256 amountToTake, uint256 amountToPay); + event PostTake(address token, uint256 amountTaken, uint256 amountPayed); + + bool public useDefault = true; + + bool public shouldRevert; + + uint256 public letKick; + + constructor( + address _asset + ) BaseAuctioneer(_asset, "Mock Auctioneer", _asset, 1 days, 5 days, 1e7) {} + + function _deployFunds(uint256) internal override {} + + function _freeFunds(uint256) internal override {} + + function _harvestAndReport() + internal + override + returns (uint256 _totalAssets) + { + _totalAssets = asset.balanceOf(address(this)); + } + + function _kickable( + address _token + ) internal view override returns (uint256) { + if (useDefault) return super._kickable(_token); + return letKick; + } + + function _auctionKicked( + address _token + ) internal override returns (uint256) { + if (useDefault) return super._auctionKicked(_token); + return letKick; + } + + function _preTake( + address _token, + uint256 _amountToTake, + uint256 _amountToPay + ) internal override { + require(!shouldRevert, "pre take revert"); + if (useDefault) return; + emit PreTake(_token, _amountToTake, _amountToPay); + } + + function _postTake( + address _token, + uint256 _amountTaken, + uint256 _amountPayed + ) internal override { + require(!shouldRevert, "post take revert"); + if (useDefault) return; + emit PostTake(_token, _amountTaken, _amountPayed); + } + + function setUseDefault(bool _useDefault) external { + useDefault = _useDefault; + } + + function setLetKick(uint256 _letKick) external { + letKick = _letKick; + } + + function setShouldRevert(bool _shouldRevert) external { + shouldRevert = _shouldRevert; + } +} + +import {IStrategy} from "@tokenized-strategy/interfaces/IStrategy.sol"; +import {IBaseAuctioneer} from "../../Bases/Auctioneer/IBaseAuctioneer.sol"; + +interface IMockAuctioneer is IStrategy, IBaseAuctioneer { + function useDefault() external view returns (bool); + + function setUseDefault(bool _useDefault) external; + + function letKick() external view returns (uint256); + + function setLetKick(uint256 _letKick) external; + + function setShouldRevert(bool _shouldRevert) external; +}