From d4c3961f84120f7f451785c88cac13c649648ba7 Mon Sep 17 00:00:00 2001 From: Schlag <89420541+Schlagonia@users.noreply.github.com> Date: Thu, 22 Feb 2024 18:17:14 -0700 Subject: [PATCH] build: dutch auction and factory (#36) * build: auction swapper * build: auction factory * fix: transfer to specific address * chore: comments * build: factory fixes * chore: auction tweaks * test: auction * chore: add reentrancy gaurd * fix: uni swapper * build: callback * feat: add callback * test: swapper * fix: small fixes * fix: remove extra check Co-authored-by: FP <83050944+fp-crypto@users.noreply.github.com> * feat: fp reviewssssszz * feat: pack it up * build: add hook flags * test: hook flags * chore: comments * feat: track enabled auctions * feat: check decimals and optional index * chore: deploy * fix: interface --------- Co-authored-by: FP <83050944+fp-crypto@users.noreply.github.com> --- .solhint.json | 3 +- script/DeployAuctionFactory.sol | 36 + src/AprOracle/AprOracle.sol | 2 +- src/Auctions/Auction.sol | 709 ++++++++++++++++++++ src/Auctions/AuctionFactory.sol | 212 ++++++ src/interfaces/ITaker.sol | 12 + src/libraries/Maths.sol | 81 +++ src/swappers/AuctionSwapper.sol | 215 ++++++ src/swappers/interfaces/IAuctionSwapper.sol | 16 + src/test/Auction.t.sol | 445 ++++++++++++ src/test/AuctionSwapper.t.sol | 548 +++++++++++++++ src/test/mocks/MockAuctionSwapper.sol | 111 +++ src/test/utils/Setup.sol | 1 + 13 files changed, 2389 insertions(+), 2 deletions(-) create mode 100644 script/DeployAuctionFactory.sol create mode 100644 src/Auctions/Auction.sol create mode 100644 src/Auctions/AuctionFactory.sol create mode 100644 src/interfaces/ITaker.sol create mode 100644 src/libraries/Maths.sol create mode 100644 src/swappers/AuctionSwapper.sol create mode 100644 src/swappers/interfaces/IAuctionSwapper.sol create mode 100644 src/test/Auction.t.sol create mode 100644 src/test/AuctionSwapper.t.sol create mode 100644 src/test/mocks/MockAuctionSwapper.sol diff --git a/.solhint.json b/.solhint.json index 6c8abcd..b1d4ada 100644 --- a/.solhint.json +++ b/.solhint.json @@ -14,6 +14,7 @@ "not-rely-on-time": "off", "private-vars-leading-underscore": "off", "reason-string": ["warn", { "maxLength": 64 }], - "no-console": "off" + "no-console": "off", + "custom-errors": "off" } } \ No newline at end of file diff --git a/script/DeployAuctionFactory.sol b/script/DeployAuctionFactory.sol new file mode 100644 index 0000000..fbd2ed0 --- /dev/null +++ b/script/DeployAuctionFactory.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.18; + +import "forge-std/Script.sol"; + +// Deploy a contract to a deterministic address with create2 +contract DeployAuctionFactory is Script { + + Deployer public deployer = Deployer(0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5Ed); + + function run() external { + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + vm.startBroadcast(deployerPrivateKey); + + // Get the bytecode + bytes memory bytecode = abi.encodePacked(vm.getCode("AuctionFactory.sol:AuctionFactory")); + + // Pick an unique salt + bytes32 salt = keccak256("Auction Factory"); + + address contractAddress = deployer.deployCreate2(salt, bytecode); + + console.log("Address is ", contractAddress); + + vm.stopBroadcast(); + } +} + +interface Deployer { + event ContractCreation(address indexed newContract, bytes32 indexed salt); + + function deployCreate2( + bytes32 salt, + bytes memory initCode + ) external payable returns (address newContract); +} \ No newline at end of file diff --git a/src/AprOracle/AprOracle.sol b/src/AprOracle/AprOracle.sol index 8e4134f..b5a68fe 100644 --- a/src/AprOracle/AprOracle.sol +++ b/src/AprOracle/AprOracle.sol @@ -7,7 +7,7 @@ import {IStrategy} from "@tokenized-strategy/interfaces/IStrategy.sol"; interface IOracle { function aprAfterDebtChange( - address _asset, + address _strategy, int256 _delta ) external view returns (uint256); } diff --git a/src/Auctions/Auction.sol b/src/Auctions/Auction.sol new file mode 100644 index 0000000..0cf6815 --- /dev/null +++ b/src/Auctions/Auction.sol @@ -0,0 +1,709 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.18; + +import {Maths} from "../libraries/Maths.sol"; +import {Governance} from "../utils/Governance.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"; + +/// @notice Interface that the optional `hook` contract should implement if the non-standard logic is desired. +interface IHook { + function kickable(address _fromToken) external view returns (uint256); + + function auctionKicked(address _fromToken) external returns (uint256); + + function preTake( + address _fromToken, + uint256 _amountToTake, + uint256 _amountToPay + ) external; + + function postTake( + address _toToken, + uint256 _amountTaken, + uint256 _amountPayed + ) external; +} + +/** + * @title Auction + * @author yearn.fi + * @notice General use dutch auction contract for token sales. + */ +contract Auction is Governance, 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; + address receiver; + uint128 initialAvailable; + uint128 currentAvailable; + } + + /// @notice Store the hook address and each flag in one slot. + struct Hook { + address hook; + bool kickable; + bool kick; + bool preTake; + bool postTake; + } + + 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 `want`. + TokenInfo internal wantInfo; + + /// @notice Contract to call during write functions. + Hook internal hook_; + + /// @notice The amount to start the auction at. + uint256 public startingPrice; + + /// @notice The time that each auction lasts. + uint256 public auctionLength; + + /// @notice The minimum time to wait between auction 'kicks'. + uint256 public auctionCooldown; + + /// @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; + + constructor() Governance(msg.sender) {} + + /** + * @notice Initializes the Auction contract with initial parameters. + * @param _want Address this auction is selling to. + * @param _hook Address of the hook contract (optional). + * @param _governance Address of the contract governance. + * @param _auctionLength Duration of each auction in seconds. + * @param _auctionCooldown Cooldown period between auctions in seconds. + * @param _startingPrice Starting price for each auction. + */ + function initialize( + address _want, + address _hook, + address _governance, + uint256 _auctionLength, + uint256 _auctionCooldown, + uint256 _startingPrice + ) external virtual { + require(auctionLength == 0, "initialized"); + require(_want != address(0), "ZERO ADDRESS"); + require(_auctionLength != 0, "length"); + require(_auctionLength < _auctionCooldown, "cooldown"); + require(_startingPrice != 0, "starting price"); + + // Cannot have more than 18 decimals. + uint256 decimals = ERC20(_want).decimals(); + require(decimals <= 18, "unsupported decimals"); + + // Set variables + wantInfo = TokenInfo({ + tokenAddress: _want, + scaler: uint96(WAD / 10 ** decimals) + }); + + // If we are using a hook. + if (_hook != address(0)) { + // All flags default to true. + hook_ = Hook({ + hook: _hook, + kickable: true, + kick: true, + preTake: true, + postTake: true + }); + } + + governance = _governance; + auctionLength = _auctionLength; + auctionCooldown = _auctionCooldown; + startingPrice = _startingPrice; + } + + /*////////////////////////////////////////////////////////////// + VIEW METHODS + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Get the address of this auctions want token. + * @return . The want token. + */ + function want() public view virtual returns (address) { + return wantInfo.tokenAddress; + } + + /** + * @notice Get the address of the hook if any. + * @return . The hook. + */ + function hook() external view virtual returns (address) { + return hook_.hook; + } + + /** + * @notice Get the current status of which hooks are being used. + * @return . If the kickable hook is used. + * @return . If the kick hook is used. + * @return . If the preTake hook is used. + * @return . If the postTake hook is used. + */ + function getHookFlags() + external + view + virtual + returns (bool, bool, bool, bool) + { + Hook memory _hook; + return (_hook.kickable, _hook.kick, _hook.preTake, _hook.postTake); + } + + /** + * @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, want(), 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, + want(), + auction.kicked, + auction.kicked + 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 + ) external view virtual returns (uint256) { + // If not enough time has passed then `kickable` is 0. + if (auctions[_auctionId].kicked + auctionCooldown > block.timestamp) { + return 0; + } + + // Check if we have a hook to call. + Hook memory _hook = hook_; + if (_hook.kickable) { + // If so default to the hooks logic. + return + IHook(_hook.hook).kickable( + auctions[_auctionId].fromInfo.tokenAddress + ); + } else { + // Else just use the full balance of this contract. + return + ERC20(auctions[_auctionId].fromInfo.tokenAddress).balanceOf( + address(this) + ); + } + } + + /** + * @notice Gets the amount of `want` 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 `want` 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 `want` 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 `want` 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 `want` 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 want. + wantInfo.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 + ) / wantInfo.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(startingPrice * 1e18, _available); + + return + (initialPrice * Maths.rmul(hoursComponent, minutesComponent)) / + 1e27; + } + + /*////////////////////////////////////////////////////////////// + SETTERS + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Enables a new auction. + * @dev Uses governance as the receiver. + * @param _from The address of the token to be auctioned. + * @return . The unique identifier of the enabled auction. + */ + function enable(address _from) external virtual returns (bytes32) { + return enable(_from, msg.sender); + } + + /** + * @notice Enables a new auction. + * @param _from The address of the token to be auctioned. + * @param _receiver The address that will receive the funds in the auction. + * @return _auctionId The unique identifier of the enabled auction. + */ + function enable( + address _from, + address _receiver + ) public virtual onlyGovernance returns (bytes32 _auctionId) { + address _want = want(); + require(_from != address(0) && _from != _want, "ZERO ADDRESS"); + require( + _receiver != address(0) && _receiver != address(this), + "receiver" + ); + // 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) + }); + auctions[_auctionId].receiver = _receiver; + + // Add to the array. + enabledAuctions.push(_auctionId); + + emit AuctionEnabled(_auctionId, _from, _want, address(this)); + } + + /** + * @notice Disables an existing auction. + * @dev Only callable by governance. + * @param _from The address of the token being sold. + */ + function disable(address _from) external virtual { + disable(_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 disable( + address _from, + uint256 _index + ) public virtual onlyGovernance { + 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, want(), address(this)); + } + + /** + * @notice Set the flags to be used with hook. + * @param _kickable If the kickable hook should be used. + * @param _kick If the kick hook should be used. + * @param _preTake If the preTake hook should be used. + * @param _postTake If the postTake should be used. + */ + function setHookFlags( + bool _kickable, + bool _kick, + bool _preTake, + bool _postTake + ) external virtual onlyGovernance { + address _hook = hook_.hook; + require(_hook != address(0), "no hook set"); + + hook_ = Hook({ + hook: _hook, + kickable: _kickable, + kick: _kick, + preTake: _preTake, + postTake: _postTake + }); + } + + /*////////////////////////////////////////////////////////////// + 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 + auctionCooldown, + "too soon" + ); + + Hook memory _hook = hook_; + // Use hook if defined. + if (_hook.kick) { + available = IHook(_hook.hook).auctionKicked(_fromToken); + } else { + // Else just use current balance. + available = ERC20(_fromToken).balanceOf(address(this)); + } + + 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 + 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); + + Hook memory _hook = hook_; + if (_hook.preTake) { + // Use hook if defined. + IHook(_hook.hook).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 want address. + address _want = want(); + + // Pull `want`. + ERC20(_want).safeTransferFrom(msg.sender, auction.receiver, needed); + + // Post take hook if defined. + if (_hook.postTake) { + IHook(_hook.hook).postTake(_want, _amountTaken, needed); + } + + emit AuctionTaken(_auctionId, _amountTaken, left); + } +} diff --git a/src/Auctions/AuctionFactory.sol b/src/Auctions/AuctionFactory.sol new file mode 100644 index 0000000..a2ae3e1 --- /dev/null +++ b/src/Auctions/AuctionFactory.sol @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.18; + +import {Auction} from "./Auction.sol"; +import {Clonable} from "../utils/Clonable.sol"; + +/// @title AuctionFactory +/// @notice Deploy a new Auction. +contract AuctionFactory is Clonable { + event DeployedNewAuction(address indexed auction, address indexed want); + + /// @notice The time that each auction lasts. + uint256 public constant DEFAULT_AUCTION_LENGTH = 1 days; + + /// @notice The minimum time to wait between auction 'kicks'. + uint256 public constant DEFAULT_AUCTION_COOLDOWN = 5 days; + + /// @notice The amount to start the auction with. + uint256 public constant DEFAULT_STARTING_PRICE = 1_000_000; + + /// @notice Full array of all auctions deployed through this factory. + address[] public auctions; + + constructor() { + // Deploy the original + original = address(new Auction()); + } + + /** + * @notice Creates a new auction contract. + * @param _want Address of the token users will bid with. + * @return _newAuction Address of the newly created auction contract. + */ + function createNewAuction(address _want) external returns (address) { + return + _createNewAuction( + _want, + address(0), + msg.sender, + DEFAULT_AUCTION_LENGTH, + DEFAULT_AUCTION_COOLDOWN, + DEFAULT_STARTING_PRICE + ); + } + + /** + * @notice Creates a new auction contract. + * @param _want Address of the token users will bid with. + * @param _hook Address of the hook contract if any. + * @return _newAuction Address of the newly created auction contract. + */ + function createNewAuction( + address _want, + address _hook + ) external returns (address) { + return + _createNewAuction( + _want, + _hook, + msg.sender, + DEFAULT_AUCTION_LENGTH, + DEFAULT_AUCTION_COOLDOWN, + DEFAULT_STARTING_PRICE + ); + } + + /** + * @notice Creates a new auction contract. + * @param _want Address of the token users will bid with. + * @param _hook Address of the hook contract if any. + * @param _governance Address allowed to enable and disable auctions. + * @return _newAuction Address of the newly created auction contract. + */ + function createNewAuction( + address _want, + address _hook, + address _governance + ) external returns (address) { + return + _createNewAuction( + _want, + _hook, + _governance, + DEFAULT_AUCTION_LENGTH, + DEFAULT_AUCTION_COOLDOWN, + DEFAULT_STARTING_PRICE + ); + } + + /** + * @notice Creates a new auction contract. + * @param _want Address of the token users will bid with. + * @param _hook Address of the hook contract if any. + * @param _governance Address allowed to enable and disable auctions. + * @param _auctionLength Length of the auction in seconds. + * @return _newAuction Address of the newly created auction contract. + */ + function createNewAuction( + address _want, + address _hook, + address _governance, + uint256 _auctionLength + ) external returns (address) { + return + _createNewAuction( + _want, + _hook, + _governance, + _auctionLength, + DEFAULT_AUCTION_COOLDOWN, + DEFAULT_STARTING_PRICE + ); + } + + /** + * @notice Creates a new auction contract. + * @param _want Address of the token users will bid with. + * @param _hook Address of the hook contract if any. + * @param _governance Address allowed to enable and disable auctions. + * @param _auctionLength Length of the auction in seconds. + * @param _auctionCooldown Minimum time period between kicks in seconds. + * @return _newAuction Address of the newly created auction contract. + */ + function createNewAuction( + address _want, + address _hook, + address _governance, + uint256 _auctionLength, + uint256 _auctionCooldown + ) external returns (address) { + return + _createNewAuction( + _want, + _hook, + _governance, + _auctionLength, + _auctionCooldown, + DEFAULT_STARTING_PRICE + ); + } + + /** + * @notice Creates a new auction contract. + * @param _want Address of the token users will bid with. + * @param _hook Address of the hook contract if any. + * @param _governance Address allowed to enable and disable auctions. + * @param _auctionLength Length of the auction in seconds. + * @param _auctionCooldown Minimum time period between kicks in seconds. + * @param _startingPrice Starting price for the auction (no decimals). + * NOTE: The starting price should be without decimals (1k == 1_000). + * @return _newAuction Address of the newly created auction contract. + */ + function createNewAuction( + address _want, + address _hook, + address _governance, + uint256 _auctionLength, + uint256 _auctionCooldown, + uint256 _startingPrice + ) external returns (address) { + return + _createNewAuction( + _want, + _hook, + _governance, + _auctionLength, + _auctionCooldown, + _startingPrice + ); + } + + /** + * @dev Deploys and initializes a new Auction + */ + function _createNewAuction( + address _want, + address _hook, + address _governance, + uint256 _auctionLength, + uint256 _auctionCooldown, + uint256 _startingPrice + ) internal returns (address _newAuction) { + _newAuction = _clone(); + + Auction(_newAuction).initialize( + _want, + _hook, + _governance, + _auctionLength, + _auctionCooldown, + _startingPrice + ); + + auctions.push(_newAuction); + + emit DeployedNewAuction(_newAuction, _want); + } + + /** + * @notice Get the full list of auctions deployed through this factory. + */ + function getAllAuctions() external view returns (address[] memory) { + return auctions; + } + + /** + * @notice Get the total number of auctions deployed through this factory. + */ + function numberOfAuctions() external view returns (uint256) { + return auctions.length; + } +} diff --git a/src/interfaces/ITaker.sol b/src/interfaces/ITaker.sol new file mode 100644 index 0000000..d1fb999 --- /dev/null +++ b/src/interfaces/ITaker.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.18; + +interface ITaker { + function auctionTakeCallback( + bytes32 _auctionId, + address _sender, + uint256 _amountTaken, + uint256 _amountNeeded, + bytes calldata _data + ) external; +} diff --git a/src/libraries/Maths.sol b/src/libraries/Maths.sol new file mode 100644 index 0000000..a80e4a2 --- /dev/null +++ b/src/libraries/Maths.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.18; + +// Math library from https://github.com/ajna-finance/ajna-core/blob/master/src/libraries/internal/Maths.sol + +/** + @title Maths library + @notice Internal library containing common maths. + */ +library Maths { + uint256 internal constant WAD = 1e18; + uint256 internal constant RAY = 1e27; + + function wmul(uint256 x, uint256 y) internal pure returns (uint256) { + return (x * y + WAD / 2) / WAD; + } + + function floorWmul(uint256 x, uint256 y) internal pure returns (uint256) { + return (x * y) / WAD; + } + + function ceilWmul(uint256 x, uint256 y) internal pure returns (uint256) { + return (x * y + WAD - 1) / WAD; + } + + function wdiv(uint256 x, uint256 y) internal pure returns (uint256) { + return (x * WAD + y / 2) / y; + } + + function floorWdiv(uint256 x, uint256 y) internal pure returns (uint256) { + return (x * WAD) / y; + } + + function ceilWdiv(uint256 x, uint256 y) internal pure returns (uint256) { + return (x * WAD + y - 1) / y; + } + + function ceilDiv(uint256 x, uint256 y) internal pure returns (uint256) { + return (x + y - 1) / y; + } + + function max(uint256 x, uint256 y) internal pure returns (uint256) { + return x >= y ? x : y; + } + + function min(uint256 x, uint256 y) internal pure returns (uint256) { + return x <= y ? x : y; + } + + function wad(uint256 x) internal pure returns (uint256) { + return x * WAD; + } + + function rmul(uint256 x, uint256 y) internal pure returns (uint256) { + return (x * y + RAY / 2) / RAY; + } + + function rpow(uint256 x, uint256 n) internal pure returns (uint256 z) { + z = n % 2 != 0 ? x : RAY; + + for (n /= 2; n != 0; n /= 2) { + x = rmul(x, x); + + if (n % 2 != 0) { + z = rmul(z, x); + } + } + } + + /*************************/ + /*** Integer Functions ***/ + /*************************/ + + function maxInt(int256 x, int256 y) internal pure returns (int256) { + return x >= y ? x : y; + } + + function minInt(int256 x, int256 y) internal pure returns (int256) { + return x <= y ? x : y; + } +} diff --git a/src/swappers/AuctionSwapper.sol b/src/swappers/AuctionSwapper.sol new file mode 100644 index 0000000..4040df6 --- /dev/null +++ b/src/swappers/AuctionSwapper.sol @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.18; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import {AuctionFactory, Auction} from "../Auctions/AuctionFactory.sol"; + +/** + * @title AuctionSwapper + * @author yearn.fi + * @dev Helper contract for a strategy to use dutch auctions for token sales. + * + * This contract is meant to be inherited by a V3 strategy in order + * to easily integrate dutch auctions into a contract for token swaps. + * + * The strategist will need to implement a way to call `_enableAuction` + * for an token pair they want to use, or a setter to manually set the + * `auction` contract. + * + * The contract comes with all of the needed function to act as a `hook` + * contract for the specific auction contract with the ability to override + * any of the functions to implement custom hooks. + * + * NOTE: If any hooks are not desired, the strategist should also + * implement a way to call the {setHookFlags} on the auction contract + * to avoid unnecessary gas for unused functions. + */ +contract AuctionSwapper { + using SafeERC20 for ERC20; + + modifier onlyAuction() { + _isAuction(); + _; + } + + /** + * @dev Check the caller is the auction contract for hooks. + */ + function _isAuction() internal view virtual { + require(msg.sender == auction, "!auction"); + } + + /// @notice The pre-deployed Auction factory for cloning. + address public constant auctionFactory = + 0x4A14145C4977E18c719BB70E6FcBF8fBFF6F62d2; + + /// @notice Address of the specific Auction this strategy uses. + address public auction; + + /*////////////////////////////////////////////////////////////// + AUCTION STARTING AND STOPPING + //////////////////////////////////////////////////////////////*/ + + function _enableAuction( + address _from, + address _want + ) internal virtual returns (bytes32) { + return _enableAuction(_from, _want, 1 days, 3 days, 1e6); + } + + /** + * @dev Used to enable a new Auction to sell `_from` to `_want`. + * If this is the first auction enabled it will deploy a new `auction` + * contract to use from the factory. + * + * NOTE: This only supports one `_want` token per strategy. + * + * @param _from Token to sell + * @param _want Token to buy. + * @return .The auction ID. + */ + function _enableAuction( + address _from, + address _want, + uint256 _auctionLength, + uint256 _auctionCooldown, + uint256 _startingPrice + ) internal virtual returns (bytes32) { + address _auction = auction; + + // If this is the first auction. + if (_auction == address(0)) { + // Deploy a new auction + _auction = AuctionFactory(auctionFactory).createNewAuction( + _want, + address(this), + address(this), + _auctionLength, + _auctionCooldown, + _startingPrice + ); + // Store it for future use. + auction = _auction; + } else { + // Can only use one `want` per auction contract. + require(Auction(_auction).want() == _want, "wrong want"); + } + + // Enable new auction for `_from` token. + return Auction(_auction).enable(_from); + } + + /** + * @dev Disable an auction for a given token. + * @param _from The token that was being sold. + */ + function _disableAuction(address _from) internal virtual { + Auction(auction).disable(_from); + } + + /*////////////////////////////////////////////////////////////// + OPTIONAL AUCTION HOOKS + //////////////////////////////////////////////////////////////*/ + + /** + * @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) public 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) { + // Send any loose balance to the auction. + uint256 balance = ERC20(_token).balanceOf(address(this)); + if (balance != 0) ERC20(_token).safeTransfer(auction, balance); + return ERC20(_token).balanceOf(auction); + } + + /** + * @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 `want` 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 {} + + /*////////////////////////////////////////////////////////////// + AUCTION HOOKS + //////////////////////////////////////////////////////////////*/ + + /** + * @notice External hook for the auction to call during a `kick`. + * @dev Will call the internal version for the strategist to override. + * @param _token Token being kicked into auction. + * @return . The amount of `_token` to be auctioned off. + */ + function auctionKicked( + address _token + ) external virtual onlyAuction returns (uint256) { + return _auctionKicked(_token); + } + + /** + * @notice External hook for the auction to call before a `take`. + * @dev Will call the internal version for the strategist to override. + * @param _token Token being taken in the auction. + * @param _amountToTake The amount of `_token` to be sent to the taker. + * @param _amountToPay Amount of `want` that will be payed. + */ + function preTake( + address _token, + uint256 _amountToTake, + uint256 _amountToPay + ) external virtual onlyAuction { + _preTake(_token, _amountToTake, _amountToPay); + } + + /** + * @notice External hook for the auction to call after a `take` completed. + * @dev Will call the internal version for the strategist to override. + * @param _token The `want` token that was sent to the strategy. + * @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 + ) external virtual onlyAuction { + _postTake(_token, _amountTaken, _amountPayed); + } +} diff --git a/src/swappers/interfaces/IAuctionSwapper.sol b/src/swappers/interfaces/IAuctionSwapper.sol new file mode 100644 index 0000000..16ba478 --- /dev/null +++ b/src/swappers/interfaces/IAuctionSwapper.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.18; + +interface IAuctionSwapper { + function auctionFactory() external view returns (address); + + function auction() external view returns (address); + + function kickable(address _fromToken) external view returns (uint256); + + function auctionKicked(address _fromToken) external returns (uint256); + + function preTake(address _fromToken, uint256 _amountToTake) external; + + function postTake(address _toToken, uint256 _newAmount) external; +} diff --git a/src/test/Auction.t.sol b/src/test/Auction.t.sol new file mode 100644 index 0000000..20a22d3 --- /dev/null +++ b/src/test/Auction.t.sol @@ -0,0 +1,445 @@ +// 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 {ITaker} from "../interfaces/ITaker.sol"; +import {Auction, AuctionFactory} from "../Auctions/AuctionFactory.sol"; + +contract AuctionTest is Setup, ITaker { + using SafeERC20 for ERC20; + + 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 + ); + + event Callback( + bytes32 _auctionId, + address _sender, + uint256 _amountTaken, + uint256 _amountNeeded, + bytes _data + ); + + Auction public auction; + AuctionFactory public auctionFactory; + + uint256 public wantScaler; + uint256 public fromScaler; + + bool public callbackHit; + + function setUp() public override { + super.setUp(); + + auctionFactory = new AuctionFactory(); + } + + function test_setup() public { + assertEq(auctionFactory.DEFAULT_AUCTION_COOLDOWN(), 5 days); + assertEq(auctionFactory.DEFAULT_AUCTION_LENGTH(), 1 days); + assertEq(auctionFactory.DEFAULT_STARTING_PRICE(), 1e6); + } + + function test_defaults() public { + auction = Auction(auctionFactory.createNewAuction(address(asset))); + + vm.expectRevert("initialized"); + auction.initialize(address(asset), address(0), management, 1, 10, 8); + + assertEq(auction.want(), address(asset)); + assertEq(auction.hook(), address(0)); + assertEq(auction.governance(), address(this)); + assertEq( + auction.auctionLength(), + auctionFactory.DEFAULT_AUCTION_LENGTH() + ); + assertEq( + auction.auctionCooldown(), + auctionFactory.DEFAULT_AUCTION_COOLDOWN() + ); + assertEq( + auction.startingPrice(), + auctionFactory.DEFAULT_STARTING_PRICE() + ); + } + + function test_enableAuction() public { + address from = tokenAddrs["USDC"]; + auction = Auction(auctionFactory.createNewAuction(address(asset))); + + bytes32 expectedId = auction.getAuctionId(from); + + vm.expectRevert("!governance"); + vm.prank(management); + auction.enable(from); + + vm.expectEmit(true, true, true, true, address(auction)); + emit AuctionEnabled(expectedId, from, address(asset), address(auction)); + + bytes32 id = auction.enable(from); + assertEq(id, expectedId); + + assertEq(auction.numberOfEnabledAuctions(), 1); + assertEq(auction.enabledAuctions(0), expectedId); + assertEq(auction.kickable(id), 0); + assertEq(auction.getAmountNeeded(id, 1e18), 0); + assertEq(auction.price(id), 0); + + ( + address _from, + address _to, + uint256 _kicked, + uint256 _available + ) = auction.auctionInfo(id); + + assertEq(_from, from); + assertEq(_to, address(asset)); + assertEq(_kicked, 0); + assertEq(_available, 0); + + (Auction.TokenInfo memory _token, , address _receiver, , ) = auction + .auctions(id); + assertEq(_token.tokenAddress, from); + assertEq(_receiver, address(this)); + + // Kicking it reverts + vm.expectRevert("nothing to kick"); + auction.kick(id); + + // Can't re-enable + vm.expectRevert("already enabled"); + auction.enable(from); + + vm.expectRevert("already enabled"); + auction.enable(from, management); + } + + function test_disableAuction() public { + address from = tokenAddrs["USDC"]; + auction = Auction(auctionFactory.createNewAuction(address(asset))); + + vm.expectRevert("not enabled"); + auction.disable(from); + + bytes32 id = auction.enable(from); + + assertEq(auction.numberOfEnabledAuctions(), 1); + + ( + address _from, + address _to, + uint256 _kicked, + uint256 _available + ) = auction.auctionInfo(id); + + assertEq(_from, from); + assertEq(_to, address(asset)); + assertEq(_kicked, 0); + assertEq(_available, 0); + + vm.expectRevert("!governance"); + vm.prank(management); + auction.disable(from); + + vm.expectEmit(true, true, true, true, address(auction)); + emit AuctionDisabled(id, from, address(asset), address(auction)); + auction.disable(from); + + assertEq(auction.numberOfEnabledAuctions(), 0); + + (_from, _to, _kicked, _available) = auction.auctionInfo(id); + + assertEq(_from, address(0)); + assertEq(_to, address(asset)); + assertEq(_kicked, 0); + assertEq(_available, 0); + + (Auction.TokenInfo memory _token, , address _receiver, , ) = auction + .auctions(id); + assertEq(_token.tokenAddress, address(0)); + assertEq(_token.scaler, 0); + assertEq(_receiver, address(0)); + } + + function test_kickAuction(uint256 _amount) public { + vm.assume(_amount >= minFuzzAmount && _amount <= maxFuzzAmount); + + address from = tokenAddrs["WBTC"]; + auction = Auction(auctionFactory.createNewAuction(address(asset))); + + fromScaler = WAD / 10 ** ERC20(from).decimals(); + wantScaler = WAD / 10 ** ERC20(asset).decimals(); + + bytes32 id = auction.enable(from); + + assertEq(auction.kickable(id), 0); + ( + address _from, + address _to, + uint256 _kicked, + uint256 _available + ) = auction.auctionInfo(id); + + assertEq(_from, from); + assertEq(_to, address(asset)); + assertEq(_kicked, 0); + assertEq(_available, 0); + + airdrop(ERC20(from), address(auction), _amount); + + assertEq(auction.kickable(id), _amount); + (, , _kicked, _available) = auction.auctionInfo(id); + assertEq(_kicked, 0); + assertEq(_available, 0); + + vm.expectEmit(true, true, true, true, address(auction)); + emit AuctionKicked(id, _amount); + uint256 available = auction.kick(id); + + assertEq(auction.kickable(id), 0); + (, , _kicked, _available) = auction.auctionInfo(id); + assertEq(_kicked, block.timestamp); + assertEq(_available, _amount); + uint256 startingPrice = ((auction.startingPrice() * + (WAD / wantScaler)) * 1e18) / + _amount / + fromScaler; + assertEq(auction.price(id), startingPrice); + assertRelApproxEq( + auction.getAmountNeeded(id, _amount), + (startingPrice * fromScaler * _amount) / + (WAD / wantScaler) / + wantScaler, + MAX_BPS + ); + + uint256 expectedPrice = auction.price(id, block.timestamp + 100); + assertLt(expectedPrice, startingPrice); + uint256 expectedAmount = auction.getAmountNeeded( + id, + _amount, + block.timestamp + 100 + ); + assertLt( + expectedAmount, + (startingPrice * fromScaler * _amount) / + (WAD / wantScaler) / + wantScaler + ); + + skip(100); + + assertEq(auction.price(id), expectedPrice); + assertEq(auction.getAmountNeeded(id, _amount), expectedAmount); + + // Skip full auction + skip(auction.auctionLength()); + + assertEq(auction.price(id), 0); + assertEq(auction.getAmountNeeded(id, _amount), 0); + + // Can't kick a new one yet + vm.expectRevert("too soon"); + auction.kick(id); + + assertEq(auction.kickable(id), 0); + } + + function test_takeAuction_all(uint256 _amount) public { + vm.assume(_amount >= minFuzzAmount && _amount <= maxFuzzAmount); + + address from = tokenAddrs["WBTC"]; + auction = Auction(auctionFactory.createNewAuction(address(asset))); + + fromScaler = WAD / 10 ** ERC20(from).decimals(); + wantScaler = WAD / 10 ** ERC20(asset).decimals(); + + bytes32 id = auction.enable(from, address(mockStrategy)); + + airdrop(ERC20(from), address(auction), _amount); + + uint256 available = auction.kick(id); + + assertEq(auction.kickable(id), 0); + ( + address _from, + address _to, + uint256 _kicked, + uint256 _available + ) = auction.auctionInfo(id); + assertEq(_from, from); + assertEq(_to, address(asset)); + assertEq(_kicked, block.timestamp); + assertEq(_available, _amount); + + skip(auction.auctionLength() / 2); + + uint256 needed = auction.getAmountNeeded(id, _amount); + + airdrop(ERC20(asset), address(this), needed); + + ERC20(asset).safeApprove(address(auction), needed); + + uint256 before = ERC20(from).balanceOf(address(this)); + + vm.expectEmit(true, true, true, true, address(auction)); + emit AuctionTaken(id, _amount, 0); + uint256 amountTaken = auction.take(id); + + assertEq(amountTaken, _amount); + + (, , , _available) = auction.auctionInfo(id); + assertEq(_available, 0); + + assertEq(ERC20(asset).balanceOf(address(this)), 0); + assertEq(ERC20(from).balanceOf(address(this)), before + _amount); + assertEq(ERC20(from).balanceOf(address(auction)), 0); + assertEq(ERC20(asset).balanceOf(address(mockStrategy)), needed); + assertEq(ERC20(asset).balanceOf(address(auction)), 0); + } + + function test_takeAuction_part(uint256 _amount, uint16 _percent) public { + vm.assume(_amount >= minFuzzAmount && _amount <= maxFuzzAmount); + _percent = uint16(bound(uint256(_percent), 1_000, MAX_BPS)); + + address from = tokenAddrs["WBTC"]; + auction = Auction(auctionFactory.createNewAuction(address(asset))); + + fromScaler = WAD / 10 ** ERC20(from).decimals(); + wantScaler = WAD / 10 ** ERC20(asset).decimals(); + + bytes32 id = auction.enable(from, address(mockStrategy)); + + airdrop(ERC20(from), address(auction), _amount); + + auction.kick(id); + + assertEq(auction.kickable(id), 0); + ( + address _from, + address _to, + uint256 _kicked, + uint256 _available + ) = auction.auctionInfo(id); + assertEq(_from, from); + assertEq(_to, address(asset)); + assertEq(_kicked, block.timestamp); + assertEq(_available, _amount); + + skip(auction.auctionLength() / 2); + + uint256 toTake = (_amount * _percent) / MAX_BPS; + uint256 left = _amount - toTake; + uint256 needed = auction.getAmountNeeded(id, toTake); + + airdrop(ERC20(asset), address(this), needed); + + ERC20(asset).safeApprove(address(auction), needed); + + uint256 before = ERC20(from).balanceOf(address(this)); + + vm.expectEmit(true, true, true, true, address(auction)); + emit AuctionTaken(id, toTake, left); + uint256 amountTaken = auction.take(id, toTake); + + assertEq(amountTaken, toTake); + + (, , , _available) = auction.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(auction)), left); + assertEq(ERC20(asset).balanceOf(address(mockStrategy)), needed); + assertEq(ERC20(asset).balanceOf(address(auction)), 0); + } + + function test_takeAuction_callback(uint256 _amount) public { + vm.assume(_amount >= minFuzzAmount && _amount <= maxFuzzAmount); + + address from = tokenAddrs["WBTC"]; + auction = Auction(auctionFactory.createNewAuction(address(asset))); + + fromScaler = WAD / 10 ** ERC20(from).decimals(); + wantScaler = WAD / 10 ** ERC20(asset).decimals(); + + bytes32 id = auction.enable(from, address(mockStrategy)); + + airdrop(ERC20(from), address(auction), _amount); + + auction.kick(id); + + assertEq(auction.kickable(id), 0); + ( + address _from, + address _to, + uint256 _kicked, + uint256 _available + ) = auction.auctionInfo(id); + assertEq(_from, from); + assertEq(_to, address(asset)); + assertEq(_kicked, block.timestamp); + assertEq(_available, _amount); + + skip(auction.auctionLength() / 2); + + uint256 toTake = _amount / 2; + uint256 left = _amount - toTake; + uint256 needed = auction.getAmountNeeded(id, toTake); + + airdrop(ERC20(asset), address(this), needed); + + ERC20(asset).safeApprove(address(auction), needed); + + uint256 before = ERC20(from).balanceOf(address(this)); + + callbackHit = false; + bytes memory _data = new bytes(69); + + vm.expectEmit(true, true, true, true, address(this)); + emit Callback(id, address(this), toTake, needed, _data); + uint256 amountTaken = auction.take(id, toTake, address(this), _data); + + assertTrue(callbackHit); + assertEq(amountTaken, toTake); + + (, , , _available) = auction.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(auction)), left); + assertEq(ERC20(asset).balanceOf(address(mockStrategy)), needed); + assertEq(ERC20(asset).balanceOf(address(auction)), 0); + } + + // Taker call back function + function auctionTakeCallback( + bytes32 _auctionId, + address _sender, + uint256 _amountTaken, + uint256 _amountNeeded, + bytes memory _data + ) external { + callbackHit = true; + emit Callback(_auctionId, _sender, _amountTaken, _amountNeeded, _data); + } +} diff --git a/src/test/AuctionSwapper.t.sol b/src/test/AuctionSwapper.t.sol new file mode 100644 index 0000000..9b6c057 --- /dev/null +++ b/src/test/AuctionSwapper.t.sol @@ -0,0 +1,548 @@ +// 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 {IMockAuctionSwapper, MockAuctionSwapper} from "./mocks/MockAuctionSwapper.sol"; +import {Auction, AuctionFactory} from "../Auctions/AuctionFactory.sol"; + +contract AuctionSwapperTest 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 + ); + + IMockAuctionSwapper public swapper; + + Auction public auction; + AuctionFactory public auctionFactory = + AuctionFactory(0x4A14145C4977E18c719BB70E6FcBF8fBFF6F62d2); + + uint256 public wantScaler; + uint256 public fromScaler; + + function setUp() public override { + super.setUp(); + + swapper = IMockAuctionSwapper( + address(new MockAuctionSwapper(address(asset))) + ); + + vm.label(address(auctionFactory), "Auction Factory "); + vm.label(address(swapper), "Auction Swapper"); + } + + function test_enableAuction() public { + address from = tokenAddrs["USDC"]; + assertEq(swapper.auction(), address(0)); + + bytes32 id = swapper.enableAuction(from, address(asset)); + + auction = Auction(swapper.auction()); + assertNeq(address(auction), address(0)); + assertEq(auction.kickable(id), 0); + assertEq(auction.getAmountNeeded(id, 1e18), 0); + assertEq(auction.price(id), 0); + ( + address _from, + address _to, + uint256 _kicked, + uint256 _available + ) = auction.auctionInfo(id); + + assertEq(_from, from); + assertEq(_to, address(asset)); + assertEq(_kicked, 0); + assertEq(_available, 0); + + // Kicking it reverts + vm.expectRevert("nothing to kick"); + auction.kick(id); + + // Can't re-enable + vm.expectRevert("already enabled"); + swapper.enableAuction(from, address(asset)); + } + + function test_enableSecondAuction() public { + address from = tokenAddrs["USDC"]; + assertEq(swapper.auction(), address(0)); + + bytes32 id = swapper.enableAuction(from, address(asset)); + + auction = Auction(swapper.auction()); + + assertNeq(address(auction), address(0)); + assertEq(auction.kickable(id), 0); + assertEq(auction.getAmountNeeded(id, 1e18), 0); + assertEq(auction.price(id), 0); + ( + address _from, + address _to, + uint256 _kicked, + uint256 _available + ) = auction.auctionInfo(id); + + assertEq(_from, from); + assertEq(_to, address(asset)); + assertEq(_kicked, 0); + assertEq(_available, 0); + + address secondFrom = tokenAddrs["WETH"]; + + vm.expectRevert("wrong want"); + swapper.enableAuction(secondFrom, from); + + bytes32 expectedId = auction.getAuctionId(secondFrom); + + vm.expectEmit(true, true, true, true, address(auction)); + emit AuctionEnabled( + expectedId, + secondFrom, + address(asset), + address(auction) + ); + bytes32 secondId = swapper.enableAuction(secondFrom, address(asset)); + + assertEq(expectedId, secondId); + assertEq(swapper.auction(), address(auction)); + assertEq(auction.kickable(secondId), 0); + assertEq(auction.getAmountNeeded(secondId, 1e18), 0); + assertEq(auction.price(secondId), 0); + (_from, _to, _kicked, _available) = auction.auctionInfo(secondId); + + assertEq(_from, secondFrom); + assertEq(_to, address(asset)); + assertEq(_kicked, 0); + assertEq(_available, 0); + } + + function test_disableAuction() public { + address from = tokenAddrs["USDC"]; + assertEq(swapper.auction(), address(0)); + + bytes32 id = swapper.enableAuction(from, address(asset)); + + auction = Auction(swapper.auction()); + + assertNeq(address(auction), address(0)); + assertEq(auction.kickable(id), 0); + assertEq(auction.getAmountNeeded(id, 1e18), 0); + assertEq(auction.price(id), 0); + ( + address _from, + address _to, + uint256 _kicked, + uint256 _available + ) = auction.auctionInfo(id); + + assertEq(_from, from); + assertEq(_to, address(asset)); + assertEq(_kicked, 0); + assertEq(_available, 0); + + vm.expectEmit(true, true, true, true, address(auction)); + emit AuctionDisabled(id, from, address(asset), address(auction)); + swapper.disableAuction(from); + + (_from, _to, _kicked, _available) = auction.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 = swapper.enableAuction(from, address(asset)); + + auction = Auction(swapper.auction()); + + assertEq(auction.kickable(id), 0); + ( + address _from, + address _to, + uint256 _kicked, + uint256 _available + ) = auction.auctionInfo(id); + + assertEq(_from, from); + assertEq(_to, address(asset)); + assertEq(_kicked, 0); + assertEq(_available, 0); + + airdrop(ERC20(from), address(swapper), _amount); + + assertEq(auction.kickable(id), _amount); + (, , _kicked, _available) = auction.auctionInfo(id); + assertEq(_kicked, 0); + assertEq(_available, 0); + + vm.expectEmit(true, true, true, true, address(auction)); + emit AuctionKicked(id, _amount); + uint256 available = auction.kick(id); + + assertEq(ERC20(from).balanceOf(address(swapper)), 0); + assertEq(ERC20(from).balanceOf(address(auction)), _amount); + + assertEq(auction.kickable(id), 0); + (, , _kicked, _available) = auction.auctionInfo(id); + assertEq(_kicked, block.timestamp); + assertEq(_available, _amount); + uint256 startingPrice = ((auction.startingPrice() * + (WAD / wantScaler)) * 1e18) / + _amount / + fromScaler; + assertEq(auction.price(id), startingPrice); + assertRelApproxEq( + auction.getAmountNeeded(id, _amount), + (startingPrice * fromScaler * _amount) / + (WAD / wantScaler) / + wantScaler, + MAX_BPS + ); + + uint256 expectedPrice = auction.price(id, block.timestamp + 100); + assertLt(expectedPrice, startingPrice); + uint256 expectedAmount = auction.getAmountNeeded( + id, + _amount, + block.timestamp + 100 + ); + assertLt( + expectedAmount, + (startingPrice * fromScaler * _amount) / + (WAD / wantScaler) / + wantScaler + ); + + skip(100); + + assertEq(auction.price(id), expectedPrice); + assertEq(auction.getAmountNeeded(id, _amount), expectedAmount); + + // Skip full auction + skip(auction.auctionLength()); + + assertEq(auction.price(id), 0); + assertEq(auction.getAmountNeeded(id, _amount), 0); + + // Can't kick a new one yet + vm.expectRevert("too soon"); + auction.kick(id); + + assertEq(auction.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 = swapper.enableAuction(from, address(asset)); + + auction = Auction(swapper.auction()); + + airdrop(ERC20(from), address(swapper), _amount); + + auction.kick(id); + + assertEq(auction.kickable(id), 0); + ( + address _from, + address _to, + uint256 _kicked, + uint256 _available + ) = auction.auctionInfo(id); + assertEq(_from, from); + assertEq(_to, address(asset)); + assertEq(_kicked, block.timestamp); + assertEq(_available, _amount); + assertEq(ERC20(from).balanceOf(address(swapper)), 0); + assertEq(ERC20(from).balanceOf(address(auction)), _amount); + + skip(auction.auctionLength() / 2); + + uint256 toTake = (_amount * _percent) / MAX_BPS; + uint256 left = _amount - toTake; + uint256 needed = auction.getAmountNeeded(id, toTake); + + airdrop(ERC20(asset), address(this), needed); + + ERC20(asset).safeApprove(address(auction), needed); + + uint256 before = ERC20(from).balanceOf(address(this)); + + vm.expectEmit(true, true, true, true, address(auction)); + emit AuctionTaken(id, toTake, left); + uint256 amountTaken = auction.take(id, toTake); + + assertEq(amountTaken, toTake); + + (, , , _available) = auction.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(auction)), left); + assertEq(ERC20(asset).balanceOf(address(swapper)), needed); + assertEq(ERC20(asset).balanceOf(address(auction)), 0); + assertEq(ERC20(from).balanceOf(address(swapper)), 0); + } + + 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 = swapper.enableAuction(from, address(asset)); + + auction = Auction(swapper.auction()); + + assertEq(auction.kickable(id), 0); + ( + address _from, + address _to, + uint256 _kicked, + uint256 _available + ) = auction.auctionInfo(id); + + assertEq(_from, from); + assertEq(_to, address(asset)); + assertEq(_kicked, 0); + assertEq(_available, 0); + + airdrop(ERC20(from), address(swapper), _amount); + + swapper.setUseDefault(false); + + assertEq(auction.kickable(id), 0); + + uint256 kickable = _amount / 10; + swapper.setLetKick(kickable); + + assertEq(auction.kickable(id), kickable); + (, , _kicked, _available) = auction.auctionInfo(id); + assertEq(_kicked, 0); + assertEq(_available, 0); + + vm.expectEmit(true, true, true, true, address(auction)); + emit AuctionKicked(id, kickable); + uint256 available = auction.kick(id); + + assertEq(ERC20(from).balanceOf(address(swapper)), _amount - kickable); + assertEq(ERC20(from).balanceOf(address(auction)), kickable); + + assertEq(auction.kickable(id), 0); + (, , _kicked, _available) = auction.auctionInfo(id); + assertEq(_kicked, block.timestamp); + assertEq(_available, kickable); + uint256 startingPrice = ((auction.startingPrice() * + (WAD / wantScaler)) * 1e18) / + kickable / + fromScaler; + assertEq(auction.price(id), startingPrice); + assertRelApproxEq( + auction.getAmountNeeded(id, kickable), + (startingPrice * fromScaler * kickable) / + (WAD / wantScaler) / + wantScaler, + MAX_BPS + ); + + uint256 expectedPrice = auction.price(id, block.timestamp + 100); + assertLt(expectedPrice, startingPrice); + uint256 expectedAmount = auction.getAmountNeeded( + id, + kickable, + block.timestamp + 100 + ); + assertLt( + expectedAmount, + (startingPrice * fromScaler * kickable) / + (WAD / wantScaler) / + wantScaler + ); + + skip(100); + + assertEq(auction.price(id), expectedPrice); + assertEq(auction.getAmountNeeded(id, kickable), expectedAmount); + + // Skip full auction + skip(auction.auctionLength()); + + assertEq(auction.price(id), 0); + assertEq(auction.getAmountNeeded(id, kickable), 0); + + // Can't kick a new one yet + vm.expectRevert("too soon"); + auction.kick(id); + + assertEq(auction.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 = swapper.enableAuction(from, address(asset)); + + auction = Auction(swapper.auction()); + + airdrop(ERC20(from), address(swapper), _amount); + + swapper.setUseDefault(false); + + assertEq(auction.kickable(id), 0); + + uint256 kickable = _amount / 10; + swapper.setLetKick(kickable); + + auction.kick(id); + + assertEq(auction.kickable(id), 0); + ( + address _from, + address _to, + uint256 _kicked, + uint256 _available + ) = auction.auctionInfo(id); + assertEq(_from, from); + assertEq(_to, address(asset)); + assertEq(_kicked, block.timestamp); + assertEq(_available, kickable); + assertEq(ERC20(from).balanceOf(address(swapper)), _amount - kickable); + assertEq(ERC20(from).balanceOf(address(auction)), kickable); + + skip(auction.auctionLength() / 2); + + uint256 toTake = (kickable * _percent) / MAX_BPS; + uint256 left = kickable - toTake; + uint256 needed = auction.getAmountNeeded(id, toTake); + + airdrop(ERC20(asset), address(this), needed); + + ERC20(asset).safeApprove(address(auction), needed); + + uint256 before = ERC20(from).balanceOf(address(this)); + + vm.expectEmit(true, true, true, true, address(swapper)); + emit PreTake(from, toTake, needed); + vm.expectEmit(true, true, true, true, address(swapper)); + emit PostTake(address(asset), toTake, needed); + uint256 amountTaken = auction.take(id, toTake); + + assertEq(amountTaken, toTake); + + (, , , _available) = auction.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(auction)), left); + assertEq(ERC20(asset).balanceOf(address(swapper)), needed); + assertEq(ERC20(asset).balanceOf(address(auction)), 0); + } + + function test_setFlags(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 = swapper.enableAuction(from, address(asset)); + + auction = Auction(swapper.auction()); + + airdrop(ERC20(from), address(swapper), _amount); + + assertEq(auction.kickable(id), _amount); + + vm.prank(address(swapper)); + auction.setHookFlags(false, false, true, true); + + assertEq(auction.kickable(id), 0); + + vm.expectRevert("nothing to kick"); + auction.kick(id); + + vm.prank(address(swapper)); + auction.setHookFlags(false, true, true, true); + + auction.kick(id); + + assertEq(ERC20(from).balanceOf(address(auction)), _amount); + + swapper.setShouldRevert(true); + + skip(auction.auctionLength() / 2); + + uint256 toTake = (_amount * _percent) / MAX_BPS; + uint256 left = _amount - toTake; + uint256 needed = auction.getAmountNeeded(id, toTake); + + airdrop(ERC20(asset), address(this), needed); + + ERC20(asset).safeApprove(address(auction), needed); + + vm.expectRevert("pre take revert"); + auction.take(id, toTake); + + vm.prank(address(swapper)); + auction.setHookFlags(false, true, false, true); + + vm.expectRevert("post take revert"); + auction.take(id, toTake); + + vm.prank(address(swapper)); + auction.setHookFlags(false, true, false, false); + + auction.take(id, toTake); + } +} diff --git a/src/test/mocks/MockAuctionSwapper.sol b/src/test/mocks/MockAuctionSwapper.sol new file mode 100644 index 0000000..c0dd283 --- /dev/null +++ b/src/test/mocks/MockAuctionSwapper.sol @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.18; + +import {AuctionSwapper, SafeERC20} from "../../swappers/AuctionSwapper.sol"; +import {BaseStrategy, ERC20} from "@tokenized-strategy/BaseStrategy.sol"; + +contract MockAuctionSwapper is BaseStrategy, AuctionSwapper { + 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) BaseStrategy(_asset, "Mock Uni V3") {} + + function _deployFunds(uint256) internal override {} + + function _freeFunds(uint256) internal override {} + + function _harvestAndReport() + internal + override + returns (uint256 _totalAssets) + { + _totalAssets = asset.balanceOf(address(this)); + } + + function enableAuction( + address _from, + address _to + ) external returns (bytes32) { + return _enableAuction(_from, _to); + } + + function disableAuction(address _from) external { + _disableAuction(_from); + } + + function kickable(address _token) public 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); + + ERC20(_token).safeTransfer(auction, letKick); + return ERC20(_token).balanceOf(auction); + } + + 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 {IAuctionSwapper} from "../../swappers/interfaces/IAuctionSwapper.sol"; + +interface IMockAuctionSwapper is IStrategy, IAuctionSwapper { + function enableAuction( + address _from, + address _to + ) external returns (bytes32); + + function disableAuction(address _from) external; + + 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; +} diff --git a/src/test/utils/Setup.sol b/src/test/utils/Setup.sol index 1d870b4..bbee1d0 100644 --- a/src/test/utils/Setup.sol +++ b/src/test/utils/Setup.sol @@ -42,6 +42,7 @@ contract Setup is ExtendedTest { // Integer variables that will be used repeatedly. uint256 public decimals; uint256 public MAX_BPS = 10_000; + uint256 public WAD = 1e18; // Fuzz amount uint256 public maxFuzzAmount = 1e12;