From 713f26a6a835282dd9fd6d246e58a5a3f112f379 Mon Sep 17 00:00:00 2001 From: bufander Date: Tue, 13 Sep 2022 19:07:14 +0200 Subject: [PATCH] feat: added strategy with ape template --- .env.example.sh | 2 + .gitattributes | 2 + .github/workflows/lint.yml | 38 + .github/workflows/test.yml | 28 + .prettierignore | 2 + LICENSE | 21 + ape-config.yaml | 37 + commitlint.config.js | 1 + contracts/Strategy.sol | 137 +++ contracts/interfaces/ILendingPool.sol | 40 + .../ILendingPoolAddressesProvider.sol | 13 + .../interfaces/IProtocolDataProvider.sol | 26 + contracts/interfaces/IStrategyERC4626.sol | 34 + contracts/interfaces/IVault.sol | 6 + contracts/mock/VaultV3.vy | 1044 +++++++++++++++++ hardhat.config.js | 16 + package.json | 14 + requirements-dev.txt | 4 + tests/conftest.py | 135 +++ tests/test_strategy.py | 170 +++ tests/utils/constants.py | 16 + 21 files changed, 1786 insertions(+) create mode 100644 .env.example.sh create mode 100644 .gitattributes create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/test.yml create mode 100644 .prettierignore create mode 100644 LICENSE create mode 100644 ape-config.yaml create mode 100644 commitlint.config.js create mode 100644 contracts/Strategy.sol create mode 100644 contracts/interfaces/ILendingPool.sol create mode 100644 contracts/interfaces/ILendingPoolAddressesProvider.sol create mode 100644 contracts/interfaces/IProtocolDataProvider.sol create mode 100644 contracts/interfaces/IStrategyERC4626.sol create mode 100644 contracts/interfaces/IVault.sol create mode 100644 contracts/mock/VaultV3.vy create mode 100644 hardhat.config.js create mode 100644 package.json create mode 100644 requirements-dev.txt create mode 100644 tests/conftest.py create mode 100644 tests/test_strategy.py create mode 100644 tests/utils/constants.py diff --git a/.env.example.sh b/.env.example.sh new file mode 100644 index 0000000..b3379b3 --- /dev/null +++ b/.env.example.sh @@ -0,0 +1,2 @@ +export ETHERSCAN_TOKEN= +export WEB3_ALCHEMY_PROJECT_ID= \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..adb20fe --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.sol linguist-language=Solidity +*.vy linguist-language=Python diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..f44fdd5 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,38 @@ +name: lint + +on: + workflow_dispatch: + push: + branches: + - master + - develop + pull_request: + +jobs: + solidity: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 16 + - name: Install node.js dependencies + run: yarn --frozen-lockfile + - name: Run linter on *.sol and *.json + run: yarn lint:check + commits: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: wagoid/commitlint-github-action@v5 + black: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: 3.8 + - run: pip install -r requirements-dev.txt + - run: black --check --include "(tests|scripts)" . \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..f2787cb --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,28 @@ +name: tests + +on: + workflow_dispatch: + push: + branches: + - master + - develop + pull_request: + +jobs: + tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 16 + - uses: ApeWorX/github-action@v1 + - run: pip install git+https://github.com/vyperlang/vyper@v0.3.4 + - run: ape compile --force --size + - run: npm install hardhat + - run: ape test + timeout-minutes: 10 + env: + WEB3_ALCHEMY_PROJECT_ID: ${{ secrets.WEB3_ALCHEMY_PROJECT_ID }} + + diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..9b95ccd --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +.build +.cache \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0d3e8b0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 bufander + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ape-config.yaml b/ape-config.yaml new file mode 100644 index 0000000..2c00f8d --- /dev/null +++ b/ape-config.yaml @@ -0,0 +1,37 @@ +name: ape-strategy-mix + +plugins: + - name: solidity + - name: vyper + - name: hardhat + - name: alchemy + - name: etherscan + +default_ecosystem: ethereum +dependencies: + - name: openzeppelin + github: OpenZeppelin/openzeppelin-contracts + version: 4.7.3 + # TODO: Use https://github.com/jmonteer/yearn-vaults-v3 when tag available + - name: bufander + github: bufander/yearn-vaults-v3 + version: 0.0.3 + +solidity: + import_remapping: + - "@openzeppelin/contracts=openzeppelin/v4.7.3" + # TODO: Use https://github.com/jmonteer/yearn-vaults-v3 when tag available + - "@yearnvaultsv3=bufander/v0.0.3" + +ethereum: + default_network: mainnet-fork +# mainnet-fork: +# default_provider: hardhat + +hardhat: + fork: + ethereum: + mainnet: + upstream_provider: alchemy + +#test: \ No newline at end of file diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 0000000..e5f121b --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1 @@ +module.exports = {extends: ["@commitlint/config-conventional"]}; \ No newline at end of file diff --git a/contracts/Strategy.sol b/contracts/Strategy.sol new file mode 100644 index 0000000..2e0e262 --- /dev/null +++ b/contracts/Strategy.sol @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: AGPL-3.0 + +pragma solidity 0.8.14; +pragma experimental ABIEncoderV2; + +import {ERC4626BaseStrategy, IERC20} from "@yearnvaultsv3/test/ERC4626BaseStrategy.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "@openzeppelin/contracts/utils/math/Math.sol"; + +import "./interfaces/ILendingPool.sol"; +import "./interfaces/ILendingPoolAddressesProvider.sol"; +import "./interfaces/IProtocolDataProvider.sol"; +import "./interfaces/IVault.sol"; + +contract Strategy is ERC4626BaseStrategy { + using Math for uint256; + + IProtocolDataProvider public constant protocolDataProvider = + IProtocolDataProvider(0x057835Ad21a177dbdd3090bB1CAE03EaCF78Fc6d); + + address public aToken; + + constructor( + address _vault, + string memory _strategyName, + string memory _strategySymbol + ) + ERC4626BaseStrategy(_vault, IVault(_vault).asset()) + ERC20(_strategyName, _strategySymbol) + { + (address _aToken, , ) = protocolDataProvider.getReserveTokensAddresses( + IVault(_vault).asset() + ); + aToken = _aToken; + } + + function maxDeposit(address receiver) + public + view + virtual + override + returns (uint256 maxAssets) + { + maxAssets = type(uint256).max; + } + + function _freeFunds(uint256 _amount) + internal + override + returns (uint256 _amountFreed) + { + uint256 idle_amount = balanceOfAsset(); + if (_amount <= idle_amount) { + // we have enough idle assets for the vault to take + _amountFreed = _amount; + } else { + // We need to take from Aave enough to reach _amount + // We run with 'unchecked' as we are safe from underflow + unchecked { + _withdrawFromAave( + Math.min(_amount - idle_amount, balanceOfAToken()) + ); + } + _amountFreed = balanceOfAsset(); + } + } + + function totalAssets() public view override returns (uint256) { + return _totalAssets(); + } + + function _totalAssets() internal view returns (uint256) { + return balanceOfAsset() + balanceOfAToken(); + } + + function _invest() internal override { + uint256 available_to_invest = balanceOfAsset(); + require(available_to_invest > 0, "no funds to invest"); + _depositToAave(available_to_invest); + } + + function harvestTrigger() public view override returns (bool) {} + + function investTrigger() public view override returns (bool) {} + + function delegatedAssets() + public + view + override + returns (uint256 _delegatedAssets) + {} + + function _protectedTokens() + internal + view + override + returns (address[] memory _protected) + {} + + function _depositToAave(uint256 amount) internal { + ILendingPool lp = _lendingPool(); + _checkAllowance(address(lp), asset(), amount); + lp.deposit(asset(), amount, address(this), 0); + } + + function _withdrawFromAave(uint256 amount) internal { + ILendingPool lp = _lendingPool(); + _checkAllowance(address(lp), aToken, amount); + lp.withdraw(asset(), amount, address(this)); + } + + function _checkAllowance( + address _contract, + address _token, + uint256 _amount + ) internal { + if (IERC20(_token).allowance(address(this), _contract) < _amount) { + IERC20(_token).approve(_contract, 0); + IERC20(_token).approve(_contract, _amount); + } + } + + function _lendingPool() internal view returns (ILendingPool) { + return + ILendingPool( + protocolDataProvider.ADDRESSES_PROVIDER().getLendingPool() + ); + } + + function balanceOfAToken() internal view returns (uint256) { + return IERC20(aToken).balanceOf(address(this)); + } + + function balanceOfAsset() internal view returns (uint256) { + return IERC20(asset()).balanceOf(address(this)); + } +} diff --git a/contracts/interfaces/ILendingPool.sol b/contracts/interfaces/ILendingPool.sol new file mode 100644 index 0000000..f824de7 --- /dev/null +++ b/contracts/interfaces/ILendingPool.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: agpl-3.0 +pragma solidity 0.8.14; +pragma experimental ABIEncoderV2; + +interface ILendingPool { + /** + * @dev Deposits an `amount` of underlying asset into the reserve, receiving in return overlying aTokens. + * - E.g. User deposits 100 USDC and gets in return 100 aUSDC + * @param asset The address of the underlying asset to deposit + * @param amount The amount to be deposited + * @param onBehalfOf The address that will receive the aTokens, same as msg.sender if the user + * wants to receive them on his own wallet, or a different address if the beneficiary of aTokens + * is a different wallet + * @param referralCode Code used to register the integrator originating the operation, for potential rewards. + * 0 if the action is executed directly by the user, without any middle-man + **/ + function deposit( + address asset, + uint256 amount, + address onBehalfOf, + uint16 referralCode + ) external; + + /** + * @dev Withdraws an `amount` of underlying asset from the reserve, burning the equivalent aTokens owned + * E.g. User has 100 aUSDC, calls withdraw() and receives 100 USDC, burning the 100 aUSDC + * @param asset The address of the underlying asset to withdraw + * @param amount The underlying amount to be withdrawn + * - Send the value type(uint256).max in order to withdraw the whole aToken balance + * @param to Address that will receive the underlying, same as msg.sender if the user + * wants to receive it on his own wallet, or a different address if the beneficiary is a + * different wallet + * @return The final amount withdrawn + **/ + function withdraw( + address asset, + uint256 amount, + address to + ) external returns (uint256); +} diff --git a/contracts/interfaces/ILendingPoolAddressesProvider.sol b/contracts/interfaces/ILendingPoolAddressesProvider.sol new file mode 100644 index 0000000..05ce0ac --- /dev/null +++ b/contracts/interfaces/ILendingPoolAddressesProvider.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: agpl-3.0 +pragma solidity 0.8.14; + +/** + * @title LendingPoolAddressesProvider contract + * @dev Main registry of addresses part of or connected to the protocol, including permissioned roles + * - Acting also as factory of proxies and admin of those, so with right to change its implementations + * - Owned by the Aave Governance + * @author Aave + **/ +interface ILendingPoolAddressesProvider { + function getLendingPool() external view returns (address); +} diff --git a/contracts/interfaces/IProtocolDataProvider.sol b/contracts/interfaces/IProtocolDataProvider.sol new file mode 100644 index 0000000..33fbcbb --- /dev/null +++ b/contracts/interfaces/IProtocolDataProvider.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: agpl-3.0 +pragma solidity 0.8.14; +pragma experimental ABIEncoderV2; + +import {ILendingPoolAddressesProvider} from "./ILendingPoolAddressesProvider.sol"; + +interface IProtocolDataProvider { + struct TokenData { + string symbol; + address tokenAddress; + } + + function ADDRESSES_PROVIDER() + external + view + returns (ILendingPoolAddressesProvider); + + function getReserveTokensAddresses(address asset) + external + view + returns ( + address aTokenAddress, + address stableDebtTokenAddress, + address variableDebtTokenAddress + ); +} diff --git a/contracts/interfaces/IStrategyERC4626.sol b/contracts/interfaces/IStrategyERC4626.sol new file mode 100644 index 0000000..c08bd5a --- /dev/null +++ b/contracts/interfaces/IStrategyERC4626.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.14; + +interface IStrategyERC4626 { + // So indexers can keep track of this + // ****** EVENTS ****** + + error NoAccess(); + + error ProtectedToken(address token); + + error StrategyAlreadyInitialized(); + + function vault() external view returns (address _vault); + + function harvestTrigger() external view returns (bool); + + // - manual: called by governance or guard, behaves similarly to freeFunds but can incur in losses. + // - vault: called by vault.update_debt if vault is on emergencyFreeFunds mode. + // function emergencyFreeFunds(uint256 _amountToWithdraw) external; + + // - `investTrigger() -> bool`: returns true when the strategy has available funds to invest and space for them. + function investTrigger() external view returns (bool); + + // - `invest()`: strategy will invest loose funds into the strategy. only callable by keepers + function invest() external; + + // - `freeFunds(uint256 _amount)`: strategy will free/unlocked funds from the underlying protocol and leave them idle. (called by vault on update_debt) + function freeFunds(uint256 _amount) external returns (uint256 _freeFunds); + + function delegatedAssets() external view returns (uint256 _delegatedAssets); + + function migrate(address) external; +} diff --git a/contracts/interfaces/IVault.sol b/contracts/interfaces/IVault.sol new file mode 100644 index 0000000..659fe15 --- /dev/null +++ b/contracts/interfaces/IVault.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.14; + +interface IVault { + function asset() external view returns (address _asset); +} diff --git a/contracts/mock/VaultV3.vy b/contracts/mock/VaultV3.vy new file mode 100644 index 0000000..ef6d6c4 --- /dev/null +++ b/contracts/mock/VaultV3.vy @@ -0,0 +1,1044 @@ +# @version 0.3.4 + +from vyper.interfaces import ERC20 +from vyper.interfaces import ERC4626 +from vyper.interfaces import ERC20Detailed + +# TODO: external contract: factory +# TODO: external contract: healtcheck + +# INTERFACES # +interface IStrategy: + def asset() -> address: view + def vault() -> address: view + def balanceOf(owner: address) -> uint256: view + def maxDeposit(receiver: address) -> uint256: view + def maxWithdraw(owner: address) -> uint256: view + def withdraw(amount: uint256, receiver: address, owner: address) -> uint256: nonpayable + def deposit(assets: uint256, receiver: address) -> uint256: nonpayable + def totalAssets() -> (uint256): view + def convertToAssets(shares: uint256) -> (uint256): view + def convertToShares(assets: uint256) -> (uint256): view + +interface IFeeManager: + def assess_fees(strategy: address, gain: uint256) -> uint256: view + +# EVENTS # +# ERC4626 EVENTS +event Deposit: + sender: indexed(address) + owner: indexed(address) + assets: uint256 + shares: uint256 + +event Withdraw: + sender: indexed(address) + receiver: indexed(address) + owner: indexed(address) + assets: uint256 + shares: uint256 + +# ERC20 EVENTS +event Transfer: + sender: indexed(address) + receiver: indexed(address) + value: uint256 + +event Approval: + owner: indexed(address) + spender: indexed(address) + value: uint256 + +# STRATEGY MANAGEMENT EVENTS +event StrategyAdded: + strategy: indexed(address) + +event StrategyRevoked: + strategy: indexed(address) + +event StrategyMigrated: + old_strategy: indexed(address) + new_strategy: indexed(address) + +event StrategyReported: + strategy: indexed(address) + gain: uint256 + loss: uint256 + current_debt: uint256 + total_fees: uint256 + +# DEBT MANAGEMENT EVENTS +event DebtUpdated: + strategy: address + current_debt: uint256 + new_debt: uint256 + +# STORAGE MANAGEMENT EVENTS +event UpdateFeeManager: + fee_manager: address + +event UpdatedMaxDebtForStrategy: + sender: address + strategy: address + new_debt: uint256 + +event UpdateDepositLimit: + deposit_limit: uint256 + +event UpdateMinimumTotalIdle: + minimum_total_idle: uint256 + +event Shutdown: + pass + +event Sweep: + token: indexed(address) + amount: uint256 + +# STRUCTS # +struct StrategyParams: + activation: uint256 + last_report: uint256 + current_debt: uint256 + max_debt: uint256 + +# CONSTANTS # +MAX_BPS: constant(uint256) = 10_000 + +# ENUMS # +enum Roles: + STRATEGY_MANAGER + DEBT_MANAGER + EMERGENCY_MANAGER + ACCOUNTING_MANAGER + +# IMMUTABLE # +ASSET: immutable(ERC20) +DECIMALS: immutable(uint256) + +# CONSTANTS # +API_VERSION: constant(String[28]) = "0.1.0" + +# STORAGE # +# HashMap that records all the strategies that are allowed to receive assets from the vault +strategies: public(HashMap[address, StrategyParams]) +# ERC20 - amount of shares per account +balance_of: HashMap[address, uint256] +# ERC20 - owner -> (spender -> amount) +allowance: public(HashMap[address, HashMap[address, uint256]]) + +# Total amount of shares that are currently minted +total_supply: uint256 +# Total amount of assets that has been deposited in strategies +total_debt_: uint256 +# Current assets held in the vault contract. Replacing balanceOf(this) to avoid price_per_share manipulation +total_idle: public(uint256) +# Minimum amount of assets that should be kept in the vault contract to allow for fast, cheap redeems +minimum_total_idle: public(uint256) +# Maximum amount of tokens that the vault can accept. If totalAssets > deposit_limit, deposits will revert +deposit_limit: public(uint256) +# TODO: remove +fee_manager: public(address) +# TODO: remove +health_check: public(address) +# HashMap mapping addresses to their roles +roles: public(HashMap[address, Roles]) +# Address that can add and remove addresses to roles +role_manager: public(address) +# Temporary variable to store the address of the next role_manager until the role is accepted +future_role_manager: public(address) +# State of the vault - if set to true, only withdrawals will be available. It can't be reverted +shutdown: public(bool) + +# ERC20 - name of the token +name: public(String[64]) +# ERC20 - symbol of the token +symbol: public(String[32]) + +# `nonces` track `permit` approvals with signature. +nonces: public(HashMap[address, uint256]) +DOMAIN_TYPE_HASH: constant(bytes32) = keccak256('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)') +PERMIT_TYPE_HASH: constant(bytes32) = keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)") +# Timestamp when profits are fully unlocked. Type uint256, as we are making comparison with block.timestamp (uint256) +profit_end_date: public(uint256) +# Last time buffer values were updated +profit_last_update: public(uint256) +# Assets per second in which profits are being unlocked (assets/second) +profit_distribution_rate_: uint256 +# Max time profits need to be locked for (seconds) +PROFIT_MAX_UNLOCK_TIME: immutable(uint256) + +# Constructor +@external +def __init__(asset: ERC20, name: String[64], symbol: String[32], role_manager: address, profit_max_unlock_time: uint256): + ASSET = asset + DECIMALS = convert(ERC20Detailed(asset.address).decimals(), uint256) + + self.name = name + self.symbol = symbol + + self.role_manager = role_manager + self.shutdown = False + + PROFIT_MAX_UNLOCK_TIME = profit_max_unlock_time + self.profit_last_update = block.timestamp + self.profit_end_date = block.timestamp + +## SHARE MANAGEMENT ## +## ERC20 ## +@internal +def _spend_allowance(owner: address, spender: address, amount: uint256): + # Unlimited approval does nothing (saves an SSTORE) + if (self.allowance[owner][spender] < max_value(uint256)): + current_allowance: uint256 = self.allowance[owner][spender] + assert current_allowance >= amount, "insufficient allowance" + self._approve(owner, spender, current_allowance - amount) + +@internal +def _transfer(sender: address, receiver: address, amount: uint256): + # Protect people from accidentally sending their shares to bad places + assert receiver not in [self, empty(address)] + assert self.balance_of[sender] >= amount, "insufficient funds" + self.balance_of[sender] -= amount + self.balance_of[receiver] += amount + log Transfer(sender, receiver, amount) + +@internal +def _transfer_from(sender: address, receiver: address, amount: uint256) -> bool: + self._spend_allowance(sender, msg.sender, amount) + self._transfer(sender, receiver, amount) + return True + +@internal +def _approve(owner: address, spender: address, amount: uint256) -> bool: + self.allowance[owner][spender] = amount + log Approval(owner, spender, amount) + return True + +@internal +def _increase_allowance(owner: address, spender: address, amount: uint256) -> bool: + self.allowance[owner][spender] += amount + log Approval(owner, spender, self.allowance[owner][spender]) + return True + +@internal +def _decrease_allowance(owner: address, spender: address, amount: uint256) -> bool: + self.allowance[owner][spender] -= amount + log Approval(owner, spender, self.allowance[owner][spender]) + return True + +# TODO: review correct implementation +@internal +def _permit(owner: address, spender: address, amount: uint256, expiry: uint256, signature: Bytes[65]) -> bool: + assert owner != empty(address), "invalid owner" + assert expiry == 0 or expiry >= block.timestamp, "permit expired" + nonce: uint256 = self.nonces[owner] + digest: bytes32 = keccak256( + concat( + b'\x19\x01', + self.domain_separator(), + keccak256( + concat( + PERMIT_TYPE_HASH, + convert(owner, bytes32), + convert(spender, bytes32), + convert(amount, bytes32), + convert(nonce, bytes32), + convert(expiry, bytes32), + ) + ) + ) + ) + # NOTE: signature is packed as r, s, v + r: uint256 = convert(slice(signature, 0, 32), uint256) + s: uint256 = convert(slice(signature, 32, 32), uint256) + v: uint256 = convert(slice(signature, 64, 1), uint256) + assert ecrecover(digest, v, r, s) == owner, "invalid signature" + self.allowance[owner][spender] = amount + self.nonces[owner] = nonce + 1 + log Approval(owner, spender, amount) + return True + +@view +@internal +def _total_assets() -> uint256: + """ + Total amount of assets that are in the vault and in the strategies. _total_debt includes unlocked profit + """ + return self.total_idle + self._total_debt() + +@internal +def _burn_shares(shares: uint256, owner: address): + self.balance_of[owner] -= shares + self.total_supply -= shares + log Transfer(owner, empty(address), shares) + +@view +@internal +def _convert_to_assets(shares: uint256) -> uint256: + """ + assets = shares * (total_assets / total_supply) --- (== price_per_share * shares) + """ + _total_supply: uint256 = self.total_supply + # if total_supply is 0, price_per_share is 1 + if _total_supply == 0: + return shares + + amount: uint256 = shares * self._total_assets() / self.total_supply + return amount + +@view +@internal +def _convert_to_shares(assets: uint256) -> uint256: + """ + shares = amount * (total_supply / total_assets) --- (== amount / price_per_share) + """ + _total_supply: uint256 = self.total_supply + + # if total_supply is 0, price_per_share is 1 + if _total_supply == 0: + return assets + + shares: uint256 = assets * _total_supply / self._total_assets() + return shares + + +# TODO: review in detail +@internal +def erc20_safe_transfer_from(token: address, sender: address, receiver: address, amount: uint256): + # Used only to send tokens that are not the type managed by this Vault. + # HACK: Used to handle non-compliant tokens like USDT + response: Bytes[32] = raw_call( + token, + concat( + method_id("transferFrom(address,address,uint256)"), + convert(sender, bytes32), + convert(receiver, bytes32), + convert(amount, bytes32), + ), + max_outsize=32, + ) + if len(response) > 0: + assert convert(response, bool), "Transfer failed!" + +# TODO: review in detail +@internal +def erc20_safe_transfer(token: address, receiver: address, amount: uint256): + # Used only to send tokens that are not the type managed by this Vault. + # HACK: Used to handle non-compliant tokens like USDT + response: Bytes[32] = raw_call( + token, + concat( + method_id("transfer(address,uint256)"), + convert(receiver, bytes32), + convert(amount, bytes32), + ), + max_outsize=32, + ) + if len(response) > 0: + assert convert(response, bool), "Transfer failed!" + + +@internal +def _issue_shares_for_amount(amount: uint256, recipient: address) -> uint256: + new_shares: uint256 = self._convert_to_shares(amount) + + # We don't make the function revert + if new_shares == 0: + return 0 + + self.balance_of[recipient] += new_shares + self.total_supply += new_shares + + log Transfer(empty(address), recipient, new_shares) + return new_shares + +## ERC4626 ## +@view +@internal +def _max_deposit(receiver: address) -> uint256: + _total_assets: uint256 = self._total_assets() + _deposit_limit: uint256 = self.deposit_limit + if (_total_assets >= _deposit_limit): + return 0 + + return _deposit_limit - _total_assets + +@view +@internal +def _max_redeem(owner: address) -> uint256: + # NOTE: this will return the max amount that is available to redeem using ERC4626 (which can only withdraw from the vault contract) + return min(self.balance_of[owner], self._convert_to_shares(self.total_idle)) + + +@internal +def _deposit(_sender: address, _recipient: address, _assets: uint256) -> uint256: + assert self.shutdown == False # dev: shutdown + assert _recipient not in [self, empty(address)], "invalid recipient" + assets: uint256 = _assets + + # If the amount is max_value(uint256) we assume the user wants to deposit their whole balance + if assets == max_value(uint256): + assets = ASSET.balanceOf(_sender) + + assert self._total_assets() + assets <= self.deposit_limit, "exceed deposit limit" + + shares: uint256 = self._issue_shares_for_amount(assets, _recipient) + assert shares > 0, "cannot mint zero" + + self.erc20_safe_transfer_from(ASSET.address, msg.sender, self, assets) + self.total_idle += assets + + log Deposit(_sender, _recipient, assets, shares) + + return shares + +@internal +def _redeem(sender: address, receiver: address, owner: address, shares_to_burn: uint256, strategies: DynArray[address, 10] = []) -> uint256: + if sender != owner: + self._spend_allowance(owner, sender, shares_to_burn) + + shares: uint256 = shares_to_burn + shares_balance: uint256 = self.balance_of[owner] + + if shares == max_value(uint256): + shares = shares_balance + + assert shares_balance >= shares, "insufficient shares to redeem" + assert shares > 0, "no shares to redeem" + + requested_assets: uint256 = self._convert_to_assets(shares) + + # load to memory to save gas + curr_total_idle: uint256 = self.total_idle + + # If there are not enough assets in the Vault contract, we try to free funds from strategies specified above + if requested_assets > curr_total_idle: + # load to memory to save gas + curr_total_debt: uint256 = self.total_debt_ + # If there is not enough debt on storage and there is profit being unlocked, we need to compute unlocked profit till now to fullfil requested_assets + if requested_assets > self.total_debt_: + unlocked_profit: uint256 = 0 + if self.profit_end_date > block.timestamp: + unlocked_profit = (block.timestamp - self.profit_last_update) * self.profit_distribution_rate_ / MAX_BPS + # we update last update time as profit is unlocked and will be added to storage debt afterwards + self.profit_last_update = block.timestamp + else: + unlocked_profit = (self.profit_end_date - self.profit_last_update) * self.profit_distribution_rate_ / MAX_BPS + self.profit_distribution_rate_ = 0 + curr_total_debt += unlocked_profit + + # Withdraw from strategies if insufficient total idle + assets_needed: uint256 = requested_assets - curr_total_idle + assets_to_withdraw: uint256 = 0 + for strategy in strategies: + assert self.strategies[strategy].activation != 0, "inactive strategy" + + assets_to_withdraw = min(assets_needed, IStrategy(strategy).maxWithdraw(self)) + # continue if nothing to withdraw + if assets_to_withdraw == 0: + continue + + # TODO: warning! if there are losses, the user withdrawing will not get them + IStrategy(strategy).withdraw(assets_to_withdraw, self, self) + curr_total_idle += assets_to_withdraw + curr_total_debt -= assets_to_withdraw + self.strategies[strategy].current_debt -= assets_to_withdraw + + # break if we have enough total idle to serve initial request + if requested_assets <= curr_total_idle: + break + + assets_needed -= assets_to_withdraw + + # if we exhaust the queue and still have insufficient total idle, revert + assert curr_total_idle >= requested_assets, "insufficient assets in vault" + # commit memory to storage + self.total_debt_ = curr_total_debt + + self._burn_shares(shares, owner) + self.total_idle = curr_total_idle - requested_assets + self.erc20_safe_transfer(ASSET.address, receiver, requested_assets) + + log Withdraw(sender, receiver, owner, requested_assets, shares) + + return requested_assets + +## STRATEGY MANAGEMENT ## +@internal +def _add_strategy(new_strategy: address): + assert new_strategy != empty(address), "strategy cannot be zero address" + assert IStrategy(new_strategy).asset() == ASSET.address, "invalid asset" + assert IStrategy(new_strategy).vault() == self, "invalid vault" + assert self.strategies[new_strategy].activation == 0, "strategy already active" + + self.strategies[new_strategy] = StrategyParams({ + activation: block.timestamp, + last_report: block.timestamp, + current_debt: 0, + max_debt: 0 + }) + + log StrategyAdded(new_strategy) + +# TODO: add a forcing parameter that ignores that the strategy has debt? +@internal +def _revoke_strategy(old_strategy: address): + assert self.strategies[old_strategy].activation != 0, "strategy not active" + assert self.strategies[old_strategy].current_debt == 0, "strategy has debt" + + # NOTE: strategy params are set to 0 (WARNING: it can be readded) + self.strategies[old_strategy] = StrategyParams({ + activation: 0, + last_report: 0, + current_debt: 0, + max_debt: 0 + }) + + log StrategyRevoked(old_strategy) + +@internal +def _migrate_strategy(new_strategy: address, old_strategy: address): + assert self.strategies[old_strategy].activation != 0, "old strategy not active" + assert self.strategies[old_strategy].current_debt == 0, "old strategy has debt" + assert new_strategy != empty(address), "strategy cannot be zero address" + assert IStrategy(new_strategy).asset() == ASSET.address, "invalid asset" + assert IStrategy(new_strategy).vault() == self, "invalid vault" + assert self.strategies[new_strategy].activation == 0, "strategy already active" + + migrated_strategy: StrategyParams = self.strategies[old_strategy] + + # NOTE: we add strategy with same params than the strategy being migrated + self.strategies[new_strategy] = StrategyParams({ + activation: block.timestamp, + last_report: block.timestamp, + current_debt: migrated_strategy.current_debt, + max_debt: migrated_strategy.max_debt + }) + + self._revoke_strategy(old_strategy) + + log StrategyMigrated(old_strategy, new_strategy) + +# DEBT MANAGEMENT # +# TODO: allow the caller to specify the debt for the strategy, enforcing max_debt +@internal +def _update_debt(strategy: address, target_debt: uint256) -> uint256: + """ + The vault will rebalance the debt vs target debt. Target debt must be smaller or equal strategy max_debt. + This function will compare the current debt with the target debt and will take funds or deposit new + funds to the strategy. + + The strategy can require a minimum (or a maximum) amount of funds that it wants to receive to invest. + The strategy can also reject freeing funds if they are locked. + + The vault will not invest the funds into the underlying protocol, which is responsibility of the strategy. + """ + + self._enforce_role(msg.sender, Roles.DEBT_MANAGER) + + new_debt: uint256 = target_debt + # Revert if target_debt cannot be achieved due to configured max_debt for given strategy + assert new_debt <= self.strategies[strategy].max_debt, "target debt higher than max debt" + + # TODO: evaluate consequences of a strategy returning all the funds (including last reported profit) when the profit is not unlocked yet + current_debt: uint256 = self.strategies[strategy].current_debt + + if self.shutdown: + new_debt = 0 + + assert new_debt != current_debt, "new debt equals current debt" + + if current_debt > new_debt: + # reduce debt + assets_to_withdraw: uint256 = current_debt - new_debt + + # ensure we always have minimum_total_idle when updating debt + # HACK: to save gas + minimum_total_idle: uint256 = self.minimum_total_idle + total_idle: uint256 = self.total_idle + + if total_idle + assets_to_withdraw < minimum_total_idle: + assets_to_withdraw = minimum_total_idle - total_idle + if assets_to_withdraw > current_debt: + assets_to_withdraw = current_debt + new_debt = current_debt - assets_to_withdraw + + withdrawable: uint256 = IStrategy(strategy).maxWithdraw(self) + assert withdrawable != 0, "nothing to withdraw" + + # if insufficient withdrawable, withdraw what we can + if withdrawable < assets_to_withdraw: + assets_to_withdraw = withdrawable + new_debt = current_debt - withdrawable + + # TODO: check if ERC4626 reverts if not enough assets + IStrategy(strategy).withdraw(assets_to_withdraw, self, self) + # TODO: verify that the assets where sent? + self.total_idle += assets_to_withdraw + # TODO: WARNING: we do this because there are rounding errors due to gradual profit unlocking + if assets_to_withdraw >= self.total_debt_: + self.total_debt_ = 0 + else: + self.total_debt_ -= assets_to_withdraw + + new_debt = current_debt - assets_to_withdraw + else: + # Vault is increasing debt with the strategy by sending more funds + max_deposit: uint256 = IStrategy(strategy).maxDeposit(self) + + assets_to_transfer: uint256 = new_debt - current_debt + if assets_to_transfer > max_deposit: + # TODO: should we revert? + assets_to_transfer = max_deposit + # take into consideration minimum_total_idle + # HACK: to save gas + minimum_total_idle: uint256 = self.minimum_total_idle + total_idle: uint256 = self.total_idle + + assert total_idle > minimum_total_idle, "no funds to deposit" + available_idle: uint256 = total_idle - minimum_total_idle + + # if insufficient funds to deposit, transfer only what is free + if assets_to_transfer > available_idle: + assets_to_transfer = available_idle + new_debt = current_debt + assets_to_transfer + if assets_to_transfer > 0: + ASSET.approve(strategy, assets_to_transfer) + IStrategy(strategy).deposit(assets_to_transfer, self) + ASSET.approve(strategy, 0) + self.total_idle -= assets_to_transfer + self.total_debt_ += assets_to_transfer + + new_debt = current_debt + assets_to_transfer + + self.strategies[strategy].current_debt = new_debt + + log DebtUpdated(strategy, current_debt, new_debt) + return new_debt + +## ACCOUNTING MANAGEMENT ## +@internal +def _process_report(strategy: address) -> (uint256, uint256): + """ + Processing a report means comparing the debt that the strategy has taken with the current amount of funds it is reporting + If the strategy ows less than it currently have, it means it has had a profit + Else (assets < debt) it has had a loss + + Different strategies might choose different reporting strategies: pessimistic, only realised P&L, ... + The best way to report depends on the strategy + + The profit will be distributed following a smooth curve over the next PROFIT_MAX_UNLOCK_TIME seconds. + Losses will be taken immediately + """ + assert self.strategies[strategy].activation != 0, "inactive strategy" + # Vault needs to assess + strategy_shares: uint256 = IStrategy(strategy).balanceOf(self) + total_assets: uint256 = IStrategy(strategy).convertToAssets(strategy_shares) + current_debt: uint256 = self.strategies[strategy].current_debt + # TODO: do we want to revert or we prefer to return? + assert total_assets != current_debt, "nothing to report" + + gain: uint256 = 0 + loss: uint256 = 0 + + if total_assets > current_debt: + gain = total_assets - current_debt + else: + loss = current_debt - total_assets + + # TODO: add a check for PROFIT_MAX_UNLOCK_TIME to save gas in the following lines + + # Compute unlocked profit since last time + remaining_time: uint256 = 0 + unlocked_profit: uint256 = 0 + pending_profit: uint256 = 0 + profit_distribution_rate_: uint256 = self.profit_distribution_rate_ + if profit_distribution_rate_ != 0: + profit_end_date: uint256 = self.profit_end_date + if block.timestamp > profit_end_date: + unlocked_profit = (profit_end_date - self.profit_last_update) * profit_distribution_rate_ / MAX_BPS + self.profit_distribution_rate_ = 0 + else: + unlocked_profit = (block.timestamp - self.profit_last_update) * profit_distribution_rate_ / MAX_BPS + remaining_time = profit_end_date - block.timestamp + pending_profit = profit_distribution_rate_ * remaining_time / MAX_BPS + + + # Strategy is reporting a loss + if loss > 0: + self.strategies[strategy].current_debt -= loss + + if loss >= pending_profit: + # If loss is too big for the profit buffer, we set distribution rate to zero + self.total_debt_ = self.total_debt_ + unlocked_profit - (loss - pending_profit) + self.profit_distribution_rate_ = 0 + else: + self.profit_distribution_rate_ = (pending_profit - loss) * MAX_BPS / remaining_time + self.total_debt_ += unlocked_profit + self.profit_last_update = block.timestamp + + # TODO: should we add a very low protocol management fee? (set to factory contract) + total_fees: uint256 = 0 + if gain > 0: + fee_manager: address = self.fee_manager + # if fee manager is not set, fees are zero + if fee_manager != empty(address): + total_fees = IFeeManager(fee_manager).assess_fees(strategy, gain) + # if fees are non-zero, issue shares + if total_fees > 0: + self._issue_shares_for_amount(total_fees, fee_manager) + + # update current debt after processing management fee + self.strategies[strategy].current_debt += gain + + # If there was profit, we need to lock it in the buffer + if PROFIT_MAX_UNLOCK_TIME == 0: + # Locking period is 0, we release immediately + self.total_debt_ += gain + unlocked_profit + else: + # Fees need to be released immediately to avoid price per share going down after minting the shares + if total_fees < gain: + gain_without_fees: uint256 = gain - total_fees + # NOTE: The new locking period is the weighted average between the remaining time and the PROFIT_MAX_UNLOCK_TIME. + # The weight used is the profit (pending_profit vs new_profit) + new_profit_locking_period: uint256 = (pending_profit * remaining_time + gain_without_fees * PROFIT_MAX_UNLOCK_TIME) / (pending_profit + gain_without_fees) + # TODO: WARNING: this will most probably lead to rounding errors. We need a way to mitigate this as much as possible + self.profit_distribution_rate_ = (pending_profit + gain_without_fees) * MAX_BPS / new_profit_locking_period + self.profit_end_date = block.timestamp + new_profit_locking_period + self.profit_last_update = block.timestamp + # NOTE: we update the total_debt with the amount of profit unlocked until this point (calculated above) + self.total_debt_ += unlocked_profit + total_fees + else: + # Fees are >= gain, it's like we had a loss (we will unlock as much profits as required to avoid a decrease in pps, if there is enough profit locked to cover fees) + if total_fees - gain < pending_profit: + # We unlock profit immediately, leaving the remaining time as is + # If there is pending profit, we reduce it by the difference between total_fees and gain + self.profit_distribution_rate_ = (pending_profit - (total_fees - gain)) * MAX_BPS / remaining_time + self.profit_end_date = block.timestamp + remaining_time + self.profit_last_update = block.timestamp + self.total_debt_ += unlocked_profit + total_fees + else: + # If pending profit is not enough to cover fees, price_per_share will decrease + # We unlock all the profit locked and only add gain + self.profit_distribution_rate_ = 0 + self.total_debt_ += unlocked_profit + gain + pending_profit + + self.strategies[strategy].last_report = block.timestamp + + log StrategyReported( + strategy, + gain, + loss, + self.strategies[strategy].current_debt, + total_fees + ) + return (gain, loss) + +@view +@internal +def _unlocked_profit() -> uint256: + profit_distribution_rate: uint256 = self.profit_distribution_rate_ + + # If profit_distribution_rate is equal to zero, there is no profit to unlock, otherwise we compute it + if profit_distribution_rate == 0: + return 0 + + # NOTE: Not caching `profit_end_date` as positive scenario should be a lot more common, and would add 6 gas each time + if self.profit_end_date >= block.timestamp: + # NOTE: no risk of underflow because profit_last_update will always be <= than block.timestamp + # MAX_BPS is dividing here for precision (look processReport) + return (block.timestamp - self.profit_last_update) * profit_distribution_rate / MAX_BPS + else: + return (self.profit_end_date - self.profit_last_update) * profit_distribution_rate / MAX_BPS + +@view +@internal +def _total_debt() -> uint256: + return self.total_debt_ + self._unlocked_profit() + + +@view +@internal +def _profit_distribution_rate() -> uint256: + if self.profit_end_date >= block.timestamp: + return self.profit_distribution_rate_ + # If we are past profit_end_date, it means profit is fully unlocked (rate=0) but we haven´t yet updated state on contract + return 0 + + +# SETTERS # +@external +def set_fee_manager(new_fee_manager: address): + # TODO: permissioning: CONFIG_MANAGER + self.fee_manager = new_fee_manager + log UpdateFeeManager(new_fee_manager) + +@external +def set_deposit_limit(deposit_limit: uint256): + # TODO: permissioning: CONFIG_MANAGER + self.deposit_limit = deposit_limit + log UpdateDepositLimit(deposit_limit) + +@external +def set_minimum_total_idle(minimum_total_idle: uint256): + self._enforce_role(msg.sender, Roles.DEBT_MANAGER) + self.minimum_total_idle = minimum_total_idle + log UpdateMinimumTotalIdle(minimum_total_idle) + +# ROLE MANAGEMENT # +@internal +def _enforce_role(account: address, role: Roles): + assert role in self.roles[account] # dev: not allowed + +@external +def set_role(account: address, role: Roles): + assert msg.sender == self.role_manager + self.roles[account] = role + +@external +def transfer_role_manager(role_manager: address): + assert msg.sender == self.role_manager + self.future_role_manager = role_manager + +@external +def accept_role_manager(): + assert msg.sender == self.future_role_manager + self.role_manager = msg.sender + self.future_role_manager = empty(address) + +# VAULT STATUS VIEWS +@view +@external +def price_per_share() -> uint256: + return self._convert_to_assets(10 ** DECIMALS) + +@view +@external +def available_deposit_limit() -> uint256: + if self.deposit_limit > self._total_assets(): + return self.deposit_limit - self._total_assets() + return 0 + +## ACCOUNTING MANAGEMENT ## +@external +def process_report(strategy: address) -> (uint256, uint256): + # TODO: should it be open? + self._enforce_role(msg.sender, Roles.ACCOUNTING_MANAGER) + return self._process_report(strategy) + +@external +def sweep(token: address) -> (uint256): + self._enforce_role(msg.sender, Roles.ACCOUNTING_MANAGER) + amount: uint256 = 0 + if token == ASSET.address: + amount = ASSET.balanceOf(self) - self.total_idle + else: + amount = ERC20(token).balanceOf(self) + assert amount != 0, "no dust" + ERC20(token).transfer(msg.sender, amount) + log Sweep(token, amount) + return amount + +## STRATEGY MANAGEMENT ## +@external +def add_strategy(new_strategy: address): + self._enforce_role(msg.sender, Roles.STRATEGY_MANAGER) + self._add_strategy(new_strategy) + +@external +def revoke_strategy(old_strategy: address): + self._enforce_role(msg.sender, Roles.STRATEGY_MANAGER) + self._revoke_strategy(old_strategy) + +@external +def migrate_strategy(new_strategy: address, old_strategy: address): + self._enforce_role(msg.sender, Roles.STRATEGY_MANAGER) + self._migrate_strategy(new_strategy, old_strategy) + +## DEBT MANAGEMENT ## +@external +def update_max_debt_for_strategy(strategy: address, new_max_debt: uint256): + self._enforce_role(msg.sender, Roles.DEBT_MANAGER) + assert self.strategies[strategy].activation != 0, "inactive strategy" + self.strategies[strategy].max_debt = new_max_debt + + log UpdatedMaxDebtForStrategy(msg.sender, strategy, new_max_debt) + +@external +def update_debt(strategy: address, target_debt: uint256) -> uint256: + self._enforce_role(msg.sender, Roles.DEBT_MANAGER) + return self._update_debt(strategy, target_debt) + +## EMERGENCY MANAGEMENT ## +@external +def shutdown_vault(): + self._enforce_role(msg.sender, Roles.EMERGENCY_MANAGER) + assert self.shutdown == False + self.shutdown = True + self.roles[msg.sender] = self.roles[msg.sender] | Roles.DEBT_MANAGER + log Shutdown() + + +## SHARE MANAGEMENT ## +## ERC20 + ERC4626 ## +@external +def deposit(assets: uint256, receiver: address) -> uint256: + return self._deposit(msg.sender, receiver, assets) + +@external +def mint(shares: uint256, receiver: address) -> uint256: + assets: uint256 = self._convert_to_assets(shares) + self._deposit(msg.sender, receiver, assets) + return assets + +@external +def withdraw(assets: uint256, receiver: address, owner: address, strategies: DynArray[address, 10] = []) -> uint256: + shares: uint256 = self._convert_to_shares(assets) + # TODO: withdrawal queue is empty here. Do we need to implement a custom withdrawal queue? + self._redeem(msg.sender, receiver, owner, shares, strategies) + return shares + +@external +def redeem(shares: uint256, receiver: address, owner: address, strategies: DynArray[address, 10] = []) -> uint256: + assets: uint256 = self._redeem(msg.sender, receiver, owner, shares, strategies) + return assets + +@external +def approve(spender: address, amount: uint256) -> bool: + return self._approve(msg.sender, spender, amount) + +@external +def transfer(receiver: address, amount: uint256) -> bool: + self._transfer(msg.sender, receiver, amount) + return True + +@external +def transferFrom(sender: address, receiver: address, amount: uint256) -> bool: + return self._transfer_from(sender, receiver, amount) + +## ERC20+4626 compatibility +@external +def increaseAllowance(spender: address, amount: uint256) -> bool: + return self._increase_allowance(msg.sender, spender, amount) + +@external +def decreaseAllowance(spender: address, amount: uint256) -> bool: + return self._decrease_allowance(msg.sender, spender, amount) + +@external +def permit(owner: address, spender: address, amount: uint256, expiry: uint256, signature: Bytes[65]) -> bool: + return self._permit(owner, spender, amount, expiry, signature) + +@view +@external +def balanceOf(addr: address) -> uint256: + return self.balance_of[addr] + +@view +@external +def totalSupply() -> uint256: + return self.total_supply + +@view +@external +def asset() -> address: + return ASSET.address + +@view +@external +def decimals() -> uint256: + return DECIMALS + +@view +@external +def total_debt() -> uint256: + return self._total_debt() + +@view +@external +def profit_distribution_rate() -> uint256: + return self._profit_distribution_rate() + +@view +@external +def totalAssets() -> uint256: + return self._total_assets() + +@view +@external +def convertToShares(assets: uint256) -> uint256: + return self._convert_to_shares(assets) + +@view +@external +def previewDeposit(assets: uint256) -> uint256: + return self._convert_to_shares(assets) + +@view +@external +def previewMint(shares: uint256) -> uint256: + return self._convert_to_assets(shares) + +@view +@external +def convertToAssets(shares: uint256) -> uint256: + return self._convert_to_assets(shares) + +@view +@external +def maxDeposit(receiver: address) -> uint256: + return self._max_deposit(receiver) + +@view +@external +def maxMint(receiver: address) -> uint256: + max_deposit: uint256 = self._max_deposit(receiver) + return self._convert_to_shares(max_deposit) + +@view +@external +def maxWithdraw(owner: address) -> uint256: + # NOTE: as the withdraw function that complies with ERC4626 won't withdraw from strategies, this just uses liquidity available in the vault contract + max_withdraw: uint256 = self._max_redeem(owner) # should be moved to a max_withdraw internal function + return self._convert_to_assets(max_withdraw) + +@view +@external +def maxRedeem(owner: address) -> uint256: + # NOTE: as the withdraw function that complies with ERC4626 won't withdraw from strategies, this just uses liquidity available in the vault contract + return self._max_redeem(owner) + +@view +@external +def previewWithdraw(assets: uint256) -> uint256: + return self._convert_to_shares(assets) + +@view +@external +def previewRedeem(shares: uint256) -> uint256: + return self._convert_to_assets(shares) + +@view +@external +def api_version() -> String[28]: + return API_VERSION + +# eip-1344 +@view +@internal +def domain_separator() -> bytes32: + return keccak256( + concat( + DOMAIN_TYPE_HASH, + keccak256(convert("Yearn Vault", Bytes[11])), + keccak256(convert(API_VERSION, Bytes[28])), + convert(chain.id, bytes32), + convert(self, bytes32) + ) + ) + +@view +@external +def DOMAIN_SEPARATOR() -> bytes32: + return self.domain_separator() \ No newline at end of file diff --git a/hardhat.config.js b/hardhat.config.js new file mode 100644 index 0000000..be70259 --- /dev/null +++ b/hardhat.config.js @@ -0,0 +1,16 @@ + +// See https://hardhat.org/config/ for config options. +module.exports = { + networks: { + hardhat: { + hardfork: "london", + // Base fee of 0 allows use of 0 gas price when testing + initialBaseFeePerGas: 0, + accounts: { + mnemonic: "test test test test test test test test test test test junk", + path: "m/44'/60'/0'", + count: 10 + } + }, + }, +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..01b19e1 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "devDependencies": { + "@commitlint/cli": "^17.0.0", + "@commitlint/config-conventional": "^17.0.0", + "hardhat": "^2.10.1", + "prettier": "^2.7.1", + "prettier-plugin-solidity": "^1.0.0-alpha.57", + "pretty-quick": "^3.0.2" + }, + "scripts": { + "lint": "prettier --write '**/*.sol' '**/*.json'", + "lint:check": "prettier --check '**/*.sol' ''**/*.json''" + } +} diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..1bcc6f1 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +eth-ape>=0.4.0,<0.5.0 +black==22.6.0 +pytest +ape-etherscan \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f29b523 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,135 @@ +import pytest +from ape import Contract, accounts +from utils.constants import MAX_INT, WEEK, ROLES + +# this should be the address of the ERC-20 used by the strategy/vault +ASSET_ADDRESS = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" # USDC +AASSET_ADDRESS = "0xBcca60bB61934080951369a648Fb03DF4F96263C" # AUSDC +ASSET_WHALE_ADDRESS = "0x0A59649758aa4d66E25f08Dd01271e891fe52199" # USDC WHALE + + +@pytest.fixture(scope="session") +def gov(accounts): + # TODO: can be changed to actual governance + return accounts[0] + + +@pytest.fixture(scope="session") +def strategist(accounts): + return accounts[1] + + +@pytest.fixture(scope="session") +def user(accounts): + return accounts[9] + + +@pytest.fixture(scope="session") +def asset(): + yield Contract(ASSET_ADDRESS) + + +@pytest.fixture(scope="session") +def amount(asset): + # Use 1M + return 1_000_000 * 10 ** asset.decimals() + + +@pytest.fixture(scope="session") +def atoken(): + return Contract(AASSET_ADDRESS) + + +# TODO: deploying vault, as there is no vault yet on mainnet. To be deleted once vault v3 is deployed +@pytest.fixture(scope="session") +def create_vault(project, gov): + def create_vault( + asset, + governance=gov, + deposit_limit=MAX_INT, + max_profit_locking_time=WEEK, + ): + vault = gov.deploy( + project.VaultV3, asset, "VaultV3", "AV", governance, max_profit_locking_time + ) + # set vault deposit + vault.set_deposit_limit(deposit_limit, sender=gov) + # set up fee manager + # vault.set_fee_manager(fee_manager.address, sender=gov) + + vault.set_role( + gov.address, + ROLES.STRATEGY_MANAGER | ROLES.DEBT_MANAGER | ROLES.ACCOUNTING_MANAGER, + sender=gov, + ) + return vault + + yield create_vault + + +@pytest.fixture(scope="function") +def vault(gov, asset, create_vault): + vault = create_vault(asset) + yield vault + + +@pytest.fixture +def create_strategy(project, strategist): + def create_strategy(vault): + strategy = strategist.deploy( + project.Strategy, vault.address, "strategy_name", "strategy_symbol" + ) + return strategy + + yield create_strategy + + +@pytest.fixture(scope="function") +def strategy(vault, create_strategy): + strategy = create_strategy(vault) + yield strategy + + +@pytest.fixture(scope="function") +def create_vault_and_strategy(strategy, vault, deposit_into_vault): + def create_vault_and_strategy(account, amount_into_vault): + deposit_into_vault(vault, amount_into_vault) + vault.add_strategy(strategy.address, sender=account) + return vault, strategy + + yield create_vault_and_strategy + + +@pytest.fixture(scope="function") +def deposit_into_vault(asset, gov): + def deposit_into_vault(vault, amount_to_deposit): + whale = accounts[ASSET_WHALE_ADDRESS] + asset.approve(vault.address, amount_to_deposit, sender=whale) + vault.deposit(amount_to_deposit, whale.address, sender=whale) + + yield deposit_into_vault + + +@pytest.fixture(scope="function") +def provide_strategy_with_debt(): + def provide_strategy_with_debt(account, strategy, vault, target_debt: int): + vault.update_max_debt_for_strategy( + strategy.address, target_debt, sender=account + ) + vault.update_debt(strategy.address, target_debt, sender=account) + + return provide_strategy_with_debt + + +@pytest.fixture +def user_interaction(strategy, vault, deposit_into_vault): + def user_interaction(): + # Due to the fact that Aave doesn't update internal state till new txs are + # created, we force it by creating a withdraw + awhale = "0x13873fa4B7771F3492825B00D1c37301fF41C348" + lp = Contract("0x7d2768dE32b0b80b7a3454c06BdAc94A69DDc7A9") + lp.withdraw( + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", int(10 ** 6), awhale, sender=accounts[awhale] + ) + + yield user_interaction diff --git a/tests/test_strategy.py b/tests/test_strategy.py new file mode 100644 index 0000000..57da056 --- /dev/null +++ b/tests/test_strategy.py @@ -0,0 +1,170 @@ +from ape import reverts +import pytest +from utils.constants import REL_ERROR + + +def test_strategy_constructor(asset, vault, strategy): + assert strategy.name() == "strategy_name" + assert strategy.symbol() == "strategy_symbol" + assert strategy.asset() == asset.address + assert strategy.vault() == vault.address + + +def test_withdrawable_only_with_assets( + gov, asset, create_vault_and_strategy, provide_strategy_with_debt, amount +): + vault, strategy = create_vault_and_strategy(gov, amount) + assert strategy.maxWithdraw(vault) == 0 + + # let's provide strategy with assets + new_debt = amount + provide_strategy_with_debt(gov, strategy, vault, new_debt) + + assert strategy.maxWithdraw(vault) == new_debt + assert asset.balanceOf(vault) == amount - new_debt + assert asset.balanceOf(strategy) == new_debt + + +def test_total_assets( + gov, asset, create_vault_and_strategy, provide_strategy_with_debt, amount +): + vault, strategy = create_vault_and_strategy(gov, amount) + + assert strategy.totalAssets() == 0 + + # let's provide strategy with assets + new_debt = amount + provide_strategy_with_debt(gov, strategy, vault, new_debt) + + assert strategy.totalAssets() == new_debt + assert asset.balanceOf(vault) == amount - new_debt + assert asset.balanceOf(strategy) == new_debt + + # let´s invest them + strategy.invest(sender=gov) + + # total assets should remain as it takes into consideration invested assets + assert pytest.approx(strategy.totalAssets(), rel=REL_ERROR) == new_debt + + +def test_invest( + asset, + atoken, + create_vault_and_strategy, + gov, + deposit_into_vault, + provide_strategy_with_debt, + amount, +): + vault, strategy = create_vault_and_strategy(gov, amount) + + with reverts("no funds to invest"): + strategy.invest(sender=gov) + + # let's provide strategy with assets + deposit_into_vault(vault, amount) + new_debt = amount + provide_strategy_with_debt(gov, strategy, vault, new_debt) + + total_assets = strategy.totalAssets() + assert asset.balanceOf(strategy) == total_assets + assert atoken.balanceOf(strategy) == 0 + + strategy.invest(sender=gov) + + assert asset.balanceOf(strategy) == 0 + assert atoken.balanceOf(strategy) == total_assets + + +def test_free_funds_idle_asset( + asset, atoken, create_vault_and_strategy, gov, provide_strategy_with_debt, amount +): + vault, strategy = create_vault_and_strategy(gov, amount) + + # let's provide strategy with assets + new_debt = amount + provide_strategy_with_debt(gov, strategy, vault, new_debt) + + assert asset.balanceOf(strategy) == new_debt + assert strategy.totalAssets() == new_debt + assert atoken.balanceOf(strategy) == 0 + vault_balance = asset.balanceOf(vault) + + strategy.freeFunds(9 ** 6, sender=vault) + + assert asset.balanceOf(strategy) == new_debt + assert strategy.totalAssets() == new_debt + assert asset.balanceOf(vault) == vault_balance + + +def test_withdrawable_with_assets_and_atokens( + asset, create_vault_and_strategy, gov, provide_strategy_with_debt, atoken, amount +): + vault_balance = amount + vault, strategy = create_vault_and_strategy(gov, vault_balance) + + assert strategy.maxWithdraw(vault) == 0 + + # let´s provide strategy with assets + new_debt = vault_balance // 2 + provide_strategy_with_debt(gov, strategy, vault, new_debt) + + # let´s invest them + strategy.invest(sender=gov) + + assert pytest.approx(strategy.maxWithdraw(vault), rel=REL_ERROR) == new_debt + assert asset.balanceOf(vault) == vault_balance - new_debt + assert asset.balanceOf(strategy) == 0 + assert atoken.balanceOf(strategy) == new_debt + + # Update with more debt without investing + new_new_debt = new_debt + vault_balance // 4 + provide_strategy_with_debt(gov, strategy, vault, new_new_debt) + + # strategy has already made some small profit + assert ( + pytest.approx(strategy.maxWithdraw(vault), rel=REL_ERROR) + == vault_balance // 2 + vault_balance // 4 + ) + assert asset.balanceOf(vault) == vault_balance - new_new_debt + assert asset.balanceOf(strategy) == new_new_debt - new_debt + assert atoken.balanceOf(strategy) >= new_debt + + +def test_free_funds_atokens( + asset, + atoken, + create_vault_and_strategy, + gov, + provide_strategy_with_debt, + user_interaction, + amount, +): + vault, strategy = create_vault_and_strategy(gov, amount) + + # let's provide strategy with assets + new_debt = amount + provide_strategy_with_debt(gov, strategy, vault, new_debt) + + assert asset.balanceOf(strategy) == new_debt + assert atoken.balanceOf(strategy) == 0 + assert strategy.totalAssets() == new_debt + + strategy.invest(sender=gov) + + assert asset.balanceOf(strategy) == 0 + assert pytest.approx(atoken.balanceOf(strategy), rel=REL_ERROR) == new_debt + assert pytest.approx(strategy.totalAssets(), rel=REL_ERROR) == new_debt + vault_balance = asset.balanceOf(vault) + + # Let´s force Aave pool to update + user_interaction() + + funds_to_free = 9 * 10 ** 11 + strategy.freeFunds(funds_to_free, sender=vault) + + assert asset.balanceOf(strategy) == funds_to_free + # There should be some more atokens than expected due to profit + assert atoken.balanceOf(strategy) > new_debt - funds_to_free + assert strategy.totalAssets() >= new_debt + assert asset.balanceOf(vault) == vault_balance diff --git a/tests/utils/constants.py b/tests/utils/constants.py new file mode 100644 index 0000000..8c0af0a --- /dev/null +++ b/tests/utils/constants.py @@ -0,0 +1,16 @@ +from enum import IntFlag + +DAY = 86400 +WEEK = 7 * DAY +YEAR = 31_556_952 # same value used in vault +MAX_INT = 2 ** 256 - 1 +ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" + +REL_ERROR = 1e-6 + + +class ROLES(IntFlag): + STRATEGY_MANAGER = 1 + DEBT_MANAGER = 2 + EMERGENCY_MANAGER = 4 + ACCOUNTING_MANAGER = 8