diff --git a/.github/workflows/foundry.yaml b/.github/workflows/foundry.yaml index 607d7a75..ebc1ede3 100644 --- a/.github/workflows/foundry.yaml +++ b/.github/workflows/foundry.yaml @@ -19,43 +19,16 @@ jobs: - ubuntu-latest architecture: - "x64" - python-version: - - "3.10" - node_version: - - 16 steps: - name: Checkout uses: actions/checkout@v3 with: submodules: recursive - - - name: Setup Python - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - architecture: ${{ matrix.architecture }} - - - name: Install Ape - uses: ApeWorX/github-action@v2.0 - with: - python-version: '3.10' - - - name: install vyper - run: pip install git+https://github.com/vyperlang/vyper - - - name: Compile contracts - # Compile Ape contracts to get dependencies - run: ape compile --force --size - + - name: Install Vyper run: pip install vyper==0.3.7 - - name: Use Node.js ${{ matrix.node_version }} - uses: actions/setup-node@v3 - with: - node-version: ${{ matrix.node_version }} - - name: Install Foundry uses: foundry-rs/foundry-toolchain@v1 with: diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index d513b6ab..9f2c89d4 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -28,10 +28,10 @@ jobs: with: fetch-depth: 1 - - name: Set up python 3.8 + - name: Set up python 3.9 uses: actions/setup-python@v2 with: - python-version: 3.8 + python-version: 3.9 - name: Set pip cache directory path id: pip-cache-dir-path diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 6818f86a..5fb93b6c 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,4 +1,4 @@ -name: Test +name: Ape tests on: push: @@ -20,12 +20,15 @@ jobs: steps: - uses: actions/checkout@v1 - - uses: ApeWorX/github-action@v2.0 with: python-version: '3.10' - - name: install vyper - run: pip install git+https://github.com/vyperlang/vyper + - name: install requirements + run: python3 -m pip install -r requirements.txt + + - name: install plugins + run: ape plugins install . + - name: Compile contracts # TODO: Force recompiles until ape compile caching is fixed run: ape compile --force --size @@ -34,7 +37,7 @@ jobs: - name: Setup node.js uses: actions/setup-node@v1 with: - node-version: '16.x' + node-version: '18.x' - name: Install hardhat run: npm install hardhat diff --git a/.gitignore b/.gitignore index 1f52a787..05926a83 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,5 @@ yarn.lock env cache/ out/ -.gas-snapshot \ No newline at end of file +.gas-snapshot +broadcast/ \ No newline at end of file diff --git a/.gitmodules b/.gitmodules index 665e0dd7..9e1e5be8 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,10 @@ [submodule "lib/erc4626-tests"] path = lib/erc4626-tests url = https://github.com/a16z/erc4626-tests +[submodule "lib/openzeppelin-contracts"] + path = lib/openzeppelin-contracts + url = https://github.com/OpenZeppelin/openzeppelin-contracts + release = v4.9.5 +[submodule "lib/tokenized-strategy"] + path = lib/tokenized-strategy + url = https://github.com/yearn/tokenized-strategy diff --git a/README.md b/README.md index a78d1e42..967fc858 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ This repository contains the Smart Contracts for Yearns V3 vault implementation. [Vault.vy](contracts/VaultV3.vy) - The ERC4626 compliant Vault that will handle all logic associated with deposits, withdraws, strategy management, profit reporting etc. +For the most updated deployment addresses see the [docs](https://docs.yearn.fi/developers/addresses/v3-contracts). And read more about V3 and how to manage your own multi strategy vault here https://docs.yearn.fi/developers/v3/overview + For the V3 strategy implementation see the [Tokenized Strategy](https://github.com/yearn/tokenized-strategy) repo. ## Requirements @@ -72,7 +74,7 @@ forge test Deployments of the Vault Factory are done using create2 to be at a deterministic address on any EVM chain. -Check the [docs](https://docs.yearn.fi/developers/v3/overview) for the most updated deployment address. +Check the [docs](https://docs.yearn.fi/developers/addresses/v3-contracts) for the most updated deployment address. Deployments on new chains can be done permissionlessly by anyone using the included script. ``` diff --git a/TECH_SPEC.md b/TECH_SPEC.md index a6d8b008..5b53ef93 100644 --- a/TECH_SPEC.md +++ b/TECH_SPEC.md @@ -27,9 +27,9 @@ This allows different players to deploy their own version and implement their ow ``` Example periphery contracts: -- Emergency module: it receives deposits of Vault Shares and allows the contract to call the shutdown function after a certain % of total Vault Shares have been deposited -- Debt Allocator: a smart contract that incentivize's APY / debt allocation optimization by rewarding the best debt allocation (see [yStarkDebtAllocator](https://github.com/jmonteer/ystarkdebtallocator)) -- Strategy Staking Module: a smart contract that allows players to sponsor specific strategies (so that they are added to the vault) by staking their YFI, making money if they do well and losing money if they don't. +- Role Manager: Governance contract that holds the vaults `role_manager` position to codify vault setup and ownership guidelines. (see [RoleManager](https://github.com/yearn/vault-periphery/tree/master/contracts/Managers)) +- Debt Allocator: a smart contract that optimizes between multiple strategies based on the optimal return. (see [DebAllocators](https://github.com/yearn/vault-periphery/tree/master/contracts/debtAllocators)) +- Safety Staking Module: a smart contract that allows players to sponsor specific strategies (so that they are added to the vault) by staking their YFI, making money if they do well and losing money if they don't. - Deposit Limit Module: Will dynamically adjust the deposit limit based on the depositor and arbitrary conditions. - ... ``` @@ -45,6 +45,8 @@ When deploying a new vault, it requires the following parameters: - role_manager: account that can assign and revoke Roles - profit_max_unlock_time: max amount of time profit will be locked before being distributed +All deployment variables besides the `asset` can be updated post deployment. + ## Normal Operation ### Deposits / Mints @@ -79,7 +81,7 @@ If totalAssets > currentDebt: the vault will record a profit Both loss and profit will impact strategy's debt, increasing the debt (current debt + profit) if there are profits, decreasing its debt (current debt - loss) if there are losses. #### Fees -Fee assessment and distribution are handled by the Accountant module. +Fee assessment and distribution are handled by the `accountant` module. It will report the amount of fees that need to be charged and the vault will issue shares for that amount of fees. @@ -127,11 +129,9 @@ Every role can be filled by an EOA, multi-sig or other smart contracts. Each rol The account that manages roles is a single account, set in `role_manager`. -This role_manager can be an EOA, a multi-sig or a Governance Module that relays calls. - -The vault comes with the ability to "open" every role. Meaning that any function that requires the caller to hold that role would be come permsissionless. +This role_manager can be an EOA, a multi-sig or a Governance contract that relays calls. -The vault imposes no restrictions on the role managers ability to open or close any role. **But this should be done with extreme care as most of the roles are not meant to be opened and can lead to loss of funds if done incorrectly**. +The `role_manager` can also update the vaults name and symbol as well as give out the vaults Roles. ### Strategy Management This responsibility is taken by callers with ADD_STRATEGY_MANAGER, REVOKE_STRATEGY_MANAGER and FORCE_REVOKE_MANAGER roles @@ -171,6 +171,13 @@ The vault checks that the `minimumTotalIdle` parameter is respected (i.e. there' If the strategy has more debt than the max_debt, the vault will request the funds back. These funds may be locked in the strategy, which will result in the strategy returning less funds than requested by the vault. +#### Auto Allocations +The DEBT_MANAGER can set the vaults `auto_allocate` flag to `True`. + +This will cause every deposit or mint call to end by the vault pushing as much debt as possible to the first strategy in the queue. + +NOTE: Not having at least 1 strategy in the `default_queue` with the `auto_allocate` flag will cause all deposits to revert. + #### Setting maximum debt for a specific strategy The MAX_DEBT_MANAGER can set the maximum amount of tokens the vault will allow a strategy to owe at any moment in time. diff --git a/ape-config.yaml b/ape-config.yaml index 3be01a80..2500d557 100644 --- a/ape-config.yaml +++ b/ape-config.yaml @@ -13,14 +13,13 @@ dependencies: - name: tokenized-strategy github: yearn/tokenized-strategy ref: v3.0.2 - contracts_folder: src + config_override: + contracts_folder: src solidity: version: 0.8.18 - import_remapping: - - "@openzeppelin/contracts=openzeppelin/v4.9.5" - - "@tokenized-strategy=tokenized-strategy/v3.0.2" ethereum: + evm_version: paris local: default_provider: hardhat \ No newline at end of file diff --git a/audits/Yearn V3 report Statemind.pdf b/audits/Yearn V3 report Statemind.pdf new file mode 100644 index 00000000..785253af Binary files /dev/null and b/audits/Yearn V3 report Statemind.pdf differ diff --git a/contracts/VaultFactory.vy b/contracts/VaultFactory.vy index 12c88762..4e97a8c0 100644 --- a/contracts/VaultFactory.vy +++ b/contracts/VaultFactory.vy @@ -60,24 +60,23 @@ event RemovedCustomProtocolFee: event FactoryShutdown: pass -event UpdateGovernance: - governance: indexed(address) +event GovernanceTransferred: + previousGovernance: indexed(address) + newGovernance: indexed(address) -event NewPendingGovernance: - pending_governance: indexed(address) +event UpdatePendingGovernance: + newPendingGovernance: indexed(address) -struct PFConfig: - # Percent of protocol's split of fees in Basis Points. - fee_bps: uint16 - # Address the protocol fees get paid to. - fee_recipient: address # Identifier for this version of the vault. -API_VERSION: constant(String[28]) = "3.0.2" +API_VERSION: constant(String[28]) = "3.0.3" # The max amount the protocol fee can be set to. MAX_FEE_BPS: constant(uint16) = 5_000 # 50% +# Mask used to unpack the protocol fee bps. +FEE_BPS_MASK: constant(uint256) = 2**16-1 + # The address that all newly deployed vaults are based from. VAULT_ORIGINAL: immutable(address) @@ -87,17 +86,18 @@ shutdown: public(bool) # Address that can set or change the fee configs. governance: public(address) # Pending governance waiting to be accepted. -pending_governance: public(address) +pendingGovernance: public(address) # Name for identification. name: public(String[64]) +# Protocol Fee Data is packed into a single uint256 slot +# 72 bits Empty | 160 bits fee recipient | 16 bits fee bps | 8 bits custom flag + # The default config for assessing protocol fees. -default_protocol_fee_config: public(PFConfig) +default_protocol_fee_data: uint256 # Custom fee to charge for a specific vault or strategy. -custom_protocol_fee: public(HashMap[address, uint16]) -# Represents if a custom protocol fee should be used. -use_custom_protocol_fee: public(HashMap[address, bool]) +custom_protocol_fee_data: HashMap[address, uint256] @external def __init__(name: String[64], vault_original: address, governance: address): @@ -163,24 +163,74 @@ def apiVersion() -> String[28]: @view @external -def protocol_fee_config(vault: address = msg.sender) -> PFConfig: +def protocol_fee_config(vault: address = msg.sender) -> (uint16, address): """ @notice Called during vault and strategy reports to retrieve the protocol fee to charge and address to receive the fees. @param vault Address of the vault that would be reporting. - @return The protocol fee config for the msg sender. + @return Fee in bps + @return Address of fee recipient """ # If there is a custom protocol fee set we return it. - if self.use_custom_protocol_fee[vault]: + config_data: uint256 = self.custom_protocol_fee_data[vault] + if self._unpack_custom_flag(config_data): # Always use the default fee recipient even with custom fees. - return PFConfig({ - fee_bps: self.custom_protocol_fee[vault], - fee_recipient: self.default_protocol_fee_config.fee_recipient - }) + return ( + self._unpack_protocol_fee(config_data), + self._unpack_fee_recipient(self.default_protocol_fee_data) + ) else: # Otherwise return the default config. - return self.default_protocol_fee_config + config_data = self.default_protocol_fee_data + return ( + self._unpack_protocol_fee(config_data), + self._unpack_fee_recipient(config_data) + ) + +@view +@external +def use_custom_protocol_fee(vault: address) -> bool: + """ + @notice If a custom protocol fee is used for a vault. + @param vault Address of the vault to check. + @return If a custom protocol fee is used. + """ + return self._unpack_custom_flag(self.custom_protocol_fee_data[vault]) + +@view +@internal +def _unpack_protocol_fee(config_data: uint256) -> uint16: + """ + Unpacks the protocol fee from the packed data uint. + """ + return convert(shift(config_data, -8) & FEE_BPS_MASK, uint16) + +@view +@internal +def _unpack_fee_recipient(config_data: uint256) -> address: + """ + Unpacks the fee recipient from the packed data uint. + """ + return convert(shift(config_data, -24), address) + +@view +@internal +def _unpack_custom_flag(config_data: uint256) -> bool: + """ + Unpacks the custom fee flag from the packed data uint. + """ + return config_data & 1 == 1 + +@view +@internal +def _pack_protocol_fee_data(recipient: address, fee: uint16, custom: bool) -> uint256: + """ + Packs the full protocol fee data into a single uint256 slot. + This is used for both the default fee storage as well as for custom fees. + 72 bits Empty | 160 bits fee recipient | 16 bits fee bps | 8 bits custom flag + """ + return shift(convert(recipient, uint256), 24) | shift(convert(fee, uint256), 8) | convert(custom, uint256) @external def set_protocol_fee_bps(new_protocol_fee_bps: uint16): @@ -194,14 +244,20 @@ def set_protocol_fee_bps(new_protocol_fee_bps: uint16): assert new_protocol_fee_bps <= MAX_FEE_BPS, "fee too high" # Cache the current default protocol fee. - default_config: PFConfig = self.default_protocol_fee_config - assert default_config.fee_recipient != empty(address), "no recipient" + default_fee_data: uint256 = self.default_protocol_fee_data + recipient: address = self._unpack_fee_recipient(default_fee_data) + + assert recipient != empty(address), "no recipient" # Set the new fee - self.default_protocol_fee_config.fee_bps = new_protocol_fee_bps + self.default_protocol_fee_data = self._pack_protocol_fee_data( + recipient, + new_protocol_fee_bps, + False + ) log UpdateProtocolFeeBps( - default_config.fee_bps, + self._unpack_protocol_fee(default_fee_data), new_protocol_fee_bps ) @@ -216,12 +272,16 @@ def set_protocol_fee_recipient(new_protocol_fee_recipient: address): assert msg.sender == self.governance, "not governance" assert new_protocol_fee_recipient != empty(address), "zero address" - old_recipient: address = self.default_protocol_fee_config.fee_recipient - - self.default_protocol_fee_config.fee_recipient = new_protocol_fee_recipient + default_fee_data: uint256 = self.default_protocol_fee_data + self.default_protocol_fee_data = self._pack_protocol_fee_data( + new_protocol_fee_recipient, + self._unpack_protocol_fee(default_fee_data), + False + ) + log UpdateProtocolFeeRecipient( - old_recipient, + self._unpack_fee_recipient(default_fee_data), new_protocol_fee_recipient ) @@ -238,14 +298,13 @@ def set_custom_protocol_fee_bps(vault: address, new_custom_protocol_fee: uint16) """ assert msg.sender == self.governance, "not governance" assert new_custom_protocol_fee <= MAX_FEE_BPS, "fee too high" - assert self.default_protocol_fee_config.fee_recipient != empty(address), "no recipient" + assert self._unpack_fee_recipient(self.default_protocol_fee_data) != empty(address), "no recipient" - self.custom_protocol_fee[vault] = new_custom_protocol_fee - - # If this is the first time a custom fee is set for this vault - # set the bool indicator so it returns the correct fee. - if not self.use_custom_protocol_fee[vault]: - self.use_custom_protocol_fee[vault] = True + self.custom_protocol_fee_data[vault] = self._pack_protocol_fee_data( + empty(address), + new_custom_protocol_fee, + True + ) log UpdateCustomProtocolFee(vault, new_custom_protocol_fee) @@ -259,11 +318,8 @@ def remove_custom_protocol_fee(vault: address): """ assert msg.sender == self.governance, "not governance" - # Reset the custom fee to 0. - self.custom_protocol_fee[vault] = 0 - - # Set custom fee bool back to false. - self.use_custom_protocol_fee[vault] = False + # Reset the custom fee to 0 and flag to False. + self.custom_protocol_fee_data[vault] = self._pack_protocol_fee_data(empty(address), 0, False) log RemovedCustomProtocolFee(vault) @@ -284,24 +340,26 @@ def shutdown_factory(): log FactoryShutdown() @external -def set_governance(new_governance: address): +def transferGovernance(new_governance: address): """ @notice Set the governance address @param new_governance The new governance address """ assert msg.sender == self.governance, "not governance" - self.pending_governance = new_governance + self.pendingGovernance = new_governance - log NewPendingGovernance(new_governance) + log UpdatePendingGovernance(new_governance) @external -def accept_governance(): +def acceptGovernance(): """ @notice Accept the governance address """ - assert msg.sender == self.pending_governance, "not pending governance" - self.governance = msg.sender - self.pending_governance = empty(address) + assert msg.sender == self.pendingGovernance, "not pending governance" - log UpdateGovernance(msg.sender) + old_governance: address = self.governance + + self.governance = msg.sender + self.pendingGovernance = empty(address) + log GovernanceTransferred(old_governance, msg.sender) diff --git a/contracts/VaultV3.vy b/contracts/VaultV3.vy index 8b51b81e..d45ae5b7 100644 --- a/contracts/VaultV3.vy +++ b/contracts/VaultV3.vy @@ -130,6 +130,9 @@ event UpdateDefaultQueue: event UpdateUseDefaultQueue: use_default_queue: bool +event UpdateAutoAllocate: + auto_allocate: bool + event UpdatedMaxDebtForStrategy: sender: indexed(address) strategy: indexed(address) @@ -170,7 +173,7 @@ MAX_BPS: constant(uint256) = 10_000 # Extended for profit locking calculations. MAX_BPS_EXTENDED: constant(uint256) = 1_000_000_000_000 # The version of this vault. -API_VERSION: constant(String[28]) = "3.0.2" +API_VERSION: constant(String[28]) = "3.0.3" # ENUMS # # Each permissioned function has its own Role. @@ -214,6 +217,8 @@ strategies: public(HashMap[address, StrategyParams]) default_queue: public(DynArray[address, MAX_QUEUE]) # Should the vault use the default_queue regardless whats passed in. use_default_queue: public(bool) +# Should the vault automatically allocate funds to the first strategy in queue. +auto_allocate: public(bool) ### ACCOUNTING ### # ERC20 - amount of shares per account @@ -457,16 +462,17 @@ def _convert_to_shares(assets: uint256, rounding: Rounding) -> uint256: return assets total_supply: uint256 = self._total_supply() + + # if total_supply is 0, price_per_share is 1 + if total_supply == 0: + return assets + total_assets: uint256 = self._total_assets() + # if total_Supply > 0 but total_assets == 0, price_per_share = 0 if total_assets == 0: - # if total_assets and total_supply is 0, price_per_share is 1 - if total_supply == 0: - return assets - else: - # Else if total_supply > 0 price_per_share is 0 - return 0 - + return 0 + numerator: uint256 = assets * total_supply shares: uint256 = numerator / total_assets if rounding == Rounding.ROUND_UP and numerator % total_assets != 0: @@ -499,31 +505,6 @@ def _issue_shares(shares: uint256, recipient: address): log Transfer(empty(address), recipient, shares) -@internal -def _issue_shares_for_amount(amount: uint256, recipient: address) -> uint256: - """ - Issues shares that are worth 'amount' in the underlying token (asset). - WARNING: this takes into account that any new assets have been summed - to total_assets (otherwise pps will go down). - """ - total_supply: uint256 = self._total_supply() - total_assets: uint256 = self._total_assets() - new_shares: uint256 = 0 - - # If no supply PPS = 1. - if total_supply == 0: - new_shares = amount - elif total_assets > amount: - new_shares = amount * total_supply / (total_assets - amount) - - # We don't make the function revert - if new_shares == 0: - return 0 - - self._issue_shares(new_shares, recipient) - - return new_shares - ## ERC4626 ## @view @internal @@ -602,16 +583,17 @@ def _max_withdraw( # Can't use an invalid strategy. assert self.strategies[strategy].activation != 0, "inactive strategy" + current_debt: uint256 = self.strategies[strategy].current_debt # Get the maximum amount the vault would withdraw from the strategy. to_withdraw: uint256 = min( # What we still need for the full withdraw. max_assets - have, # The current debt the strategy has. - self.strategies[strategy].current_debt + current_debt ) # Get any unrealised loss for the strategy. - unrealised_loss: uint256 = self._assess_share_of_unrealised_losses(strategy, to_withdraw) + unrealised_loss: uint256 = self._assess_share_of_unrealised_losses(strategy, current_debt, to_withdraw) # See if any limit is enforced by the strategy. strategy_limit: uint256 = IStrategy(strategy).convertToAssets( @@ -656,42 +638,16 @@ def _max_withdraw( return max_assets @internal -def _deposit(sender: address, recipient: address, assets: uint256) -> uint256: +def _deposit(recipient: address, assets: uint256, shares: uint256): """ - Used for `deposit` calls to transfer the amount of `asset` to the vault, - issue the corresponding shares to the `recipient` and update all needed + Used for `deposit` and `mint` calls to transfer the amount of `asset` to the vault, + issue the corresponding `shares` to the `recipient` and update all needed vault accounting. """ - assert self.shutdown == False # dev: shutdown assert assets <= self._max_deposit(recipient), "exceed deposit limit" - - # Transfer the tokens to the vault first. - self._erc20_safe_transfer_from(self.asset, msg.sender, self, assets) - # Record the change in total assets. - self.total_idle += assets - - # Issue the corresponding shares for assets. - shares: uint256 = self._issue_shares_for_amount(assets, recipient) - - assert shares > 0, "cannot mint zero" - - log Deposit(sender, recipient, assets, shares) - return shares - -@internal -def _mint(sender: address, recipient: address, shares: uint256) -> uint256: - """ - Used for `mint` calls to issue the corresponding shares to the `recipient`, - transfer the amount of `asset` to the vault, and update all needed vault - accounting. - """ - assert self.shutdown == False # dev: shutdown - # Get corresponding amount of assets. - assets: uint256 = self._convert_to_assets(shares, Rounding.ROUND_UP) - assert assets > 0, "cannot deposit zero" - assert assets <= self._max_deposit(recipient), "exceed deposit limit" - + assert shares > 0, "cannot mint zero" + # Transfer the tokens to the vault first. self._erc20_safe_transfer_from(self.asset, msg.sender, self, assets) # Record the change in total assets. @@ -700,12 +656,14 @@ def _mint(sender: address, recipient: address, shares: uint256) -> uint256: # Issue the corresponding shares for assets. self._issue_shares(shares, recipient) - log Deposit(sender, recipient, assets, shares) - return assets + log Deposit(msg.sender, recipient, assets, shares) + + if self.auto_allocate: + self._update_debt(self.default_queue[0], max_value(uint256), 0) @view @internal -def _assess_share_of_unrealised_losses(strategy: address, assets_needed: uint256) -> uint256: +def _assess_share_of_unrealised_losses(strategy: address, strategy_current_debt: uint256, assets_needed: uint256) -> uint256: """ Returns the share of losses that a user would take if withdrawing from this strategy This accounts for losses that have been realized at the strategy level but not yet @@ -714,8 +672,6 @@ def _assess_share_of_unrealised_losses(strategy: address, assets_needed: uint256 e.g. if the strategy has unrealised losses for 10% of its current debt and the user wants to withdraw 1_000 tokens, the losses that they will take is 100 token """ - # Minimum of how much debt the debt should be worth. - strategy_current_debt: uint256 = self.strategies[strategy].current_debt # The actual amount that the debt is currently worth. vault_shares: uint256 = IStrategy(strategy).balanceOf(self) strategy_assets: uint256 = IStrategy(strategy).convertToAssets(vault_shares) @@ -844,7 +800,7 @@ def _redeem( # NOTE: strategies need to manage the fact that realising part of the loss can # mean the realisation of 100% of the loss!! (i.e. if for withdrawing 10% of the # strategy it needs to unwind the whole position, generated losses might be bigger) - unrealised_losses_share: uint256 = self._assess_share_of_unrealised_losses(strategy, assets_to_withdraw) + unrealised_losses_share: uint256 = self._assess_share_of_unrealised_losses(strategy, current_debt, assets_to_withdraw) if unrealised_losses_share > 0: # If max withdraw is limiting the amount to pull, we need to adjust the portion of # the unrealized loss the user should take. @@ -1048,14 +1004,16 @@ def _update_debt(strategy: address, target_debt: uint256, max_loss: uint256) -> withdrawable: uint256 = IStrategy(strategy).convertToAssets( IStrategy(strategy).maxRedeem(self) ) - assert withdrawable != 0, "nothing to withdraw" # If insufficient withdrawable, withdraw what we can. if withdrawable < assets_to_withdraw: assets_to_withdraw = withdrawable + if assets_to_withdraw == 0: + return current_debt + # If there are unrealised losses we don't let the vault reduce its debt until there is a new report - unrealised_losses_share: uint256 = self._assess_share_of_unrealised_losses(strategy, assets_to_withdraw) + unrealised_losses_share: uint256 = self._assess_share_of_unrealised_losses(strategy, current_debt, assets_to_withdraw) assert unrealised_losses_share == 0, "strategy has unrealised losses" # Cache for repeated use. @@ -1088,12 +1046,18 @@ def _update_debt(strategy: address, target_debt: uint256, max_loss: uint256) -> else: # We are increasing the strategies 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" + # Respect the maximum amount allowed. + max_debt: uint256 = self.strategies[strategy].max_debt + if new_debt > max_debt: + new_debt = max_debt + # Possible for current to be greater than max from reports. + if new_debt < current_debt: + return current_debt # Vault is increasing debt with the strategy by sending more funds. max_deposit: uint256 = IStrategy(strategy).maxDeposit(self) - assert max_deposit != 0, "nothing to deposit" + if max_deposit == 0: + return current_debt # Deposit the difference between desired and current. assets_to_deposit: uint256 = new_debt - current_debt @@ -1105,7 +1069,9 @@ def _update_debt(strategy: address, target_debt: uint256, max_loss: uint256) -> minimum_total_idle: uint256 = self.minimum_total_idle total_idle: uint256 = self.total_idle - assert total_idle > minimum_total_idle, "no funds to deposit" + if total_idle <= minimum_total_idle: + return current_debt + available_idle: uint256 = unsafe_sub(total_idle, minimum_total_idle) # If insufficient funds to deposit, transfer only what is free. @@ -1162,19 +1128,33 @@ def _process_report(strategy: address) -> (uint256, uint256): Any applicable fees are charged and distributed during the report as well to the specified recipients. - """ - # Make sure we have a valid strategy. - assert self.strategies[strategy].activation != 0, "inactive strategy" - # Vault assesses profits using 4626 compliant interface. - # NOTE: It is important that a strategies `convertToAssets` implementation - # cannot be manipulated or else the vault could report incorrect gains/losses. - strategy_shares: uint256 = IStrategy(strategy).balanceOf(self) - # How much the vaults position is worth. - total_assets: uint256 = IStrategy(strategy).convertToAssets(strategy_shares) - # How much the vault had deposited to the strategy. - current_debt: uint256 = self.strategies[strategy].current_debt + Can update the vaults `totalIdle` to account for any airdropped tokens by + passing the vaults address in as the parameter. + """ + # Cache `asset` for repeated use. + _asset: address = self.asset + total_assets: uint256 = 0 + current_debt: uint256 = 0 + + if strategy != self: + # Make sure we have a valid strategy. + assert self.strategies[strategy].activation != 0, "inactive strategy" + + # Vault assesses profits using 4626 compliant interface. + # NOTE: It is important that a strategies `convertToAssets` implementation + # cannot be manipulated or else the vault could report incorrect gains/losses. + strategy_shares: uint256 = IStrategy(strategy).balanceOf(self) + # How much the vaults position is worth. + total_assets = IStrategy(strategy).convertToAssets(strategy_shares) + # How much the vault had deposited to the strategy. + current_debt = self.strategies[strategy].current_debt + else: + # Accrue any airdropped `asset` into `total_idle` + total_assets = ERC20(_asset).balanceOf(self) + current_debt = self.total_idle + gain: uint256 = 0 loss: uint256 = 0 @@ -1187,9 +1167,6 @@ def _process_report(strategy: address) -> (uint256, uint256): else: # We have a loss. loss = unsafe_sub(current_debt, total_assets) - - # Cache `asset` for repeated use. - _asset: address = self.asset ### Asses Fees and Refunds ### @@ -1276,15 +1253,21 @@ def _process_report(strategy: address) -> (uint256, uint256): if gain > 0: # NOTE: this will increase total_assets current_debt = unsafe_add(current_debt, gain) - self.strategies[strategy].current_debt = current_debt - self.total_debt += gain - + if strategy != self: + self.strategies[strategy].current_debt = current_debt + self.total_debt += gain + else: + self.total_idle = total_assets + # Or record any reported loss elif loss > 0: current_debt = unsafe_sub(current_debt, loss) - self.strategies[strategy].current_debt = current_debt - self.total_debt -= loss - + if strategy != self: + self.strategies[strategy].current_debt = current_debt + self.total_debt -= loss + else: + self.total_idle = total_assets + # Issue shares for fees that were calculated above if applicable. if total_fees_shares > 0: # Accountant fees are (total_fees - protocol_fees). @@ -1338,6 +1321,27 @@ def _process_report(strategy: address) -> (uint256, uint256): return (gain, loss) # SETTERS # + +@external +def setName(name: String[64]): + """ + @notice Change the vault name. + @dev Can only be called by the Role Manager. + @param name The new name for the vault. + """ + assert msg.sender == self.role_manager, "not allowed" + self.name = name + +@external +def setSymbol(symbol: String[32]): + """ + @notice Change the vault symbol. + @dev Can only be called by the Role Manager. + @param symbol The new name for the vault. + """ + assert msg.sender == self.role_manager, "not allowed" + self.symbol = symbol + @external def set_accountant(new_accountant: address): """ @@ -1382,6 +1386,21 @@ def set_use_default_queue(use_default_queue: bool): log UpdateUseDefaultQueue(use_default_queue) +@external +def set_auto_allocate(auto_allocate: bool): + """ + @notice Set new value for `auto_allocate` + @dev If `True` every {deposit} and {mint} call will + try and allocate the deposited amount to the strategy + at position 0 of the `default_queue` atomically. + NOTE: An empty `default_queue` will cause deposits to fail. + @param auto_allocate new value. + """ + self._enforce_role(msg.sender, Roles.DEBT_MANAGER) + self.auto_allocate = auto_allocate + + log UpdateAutoAllocate(auto_allocate) + @external def set_deposit_limit(deposit_limit: uint256, override: bool = False): """ @@ -1723,6 +1742,7 @@ def update_debt( ) -> uint256: """ @notice Update the debt for a strategy. + @dev Pass max uint256 to allocate as much idle as possible. @param strategy The strategy to update the debt for. @param target_debt The target debt for the strategy. @param max_loss Optional to check realized losses on debt decreases. @@ -1763,11 +1783,19 @@ def shutdown_vault(): def deposit(assets: uint256, receiver: address) -> uint256: """ @notice Deposit assets into the vault. + @dev Pass max uint256 to deposit full asset balance. @param assets The amount of assets to deposit. @param receiver The address to receive the shares. @return The amount of shares minted. """ - return self._deposit(msg.sender, receiver, assets) + amount: uint256 = assets + # Deposit all if sent with max uint + if amount == max_value(uint256): + amount = ERC20(self.asset).balanceOf(msg.sender) + + shares: uint256 = self._convert_to_shares(amount, Rounding.ROUND_DOWN) + self._deposit(receiver, amount, shares) + return shares @external @nonreentrant("lock") @@ -1778,7 +1806,9 @@ def mint(shares: uint256, receiver: address) -> uint256: @param receiver The address to receive the shares. @return The amount of assets deposited. """ - return self._mint(msg.sender, receiver, shares) + assets: uint256 = self._convert_to_assets(shares, Rounding.ROUND_UP) + self._deposit(receiver, assets, shares) + return assets @external @nonreentrant("lock") @@ -2087,9 +2117,10 @@ def assess_share_of_unrealised_losses(strategy: address, assets_needed: uint256) @param assets_needed The amount of assets needed to be withdrawn. @return The share of unrealised losses that the strategy has. """ - assert self.strategies[strategy].current_debt >= assets_needed + current_debt: uint256 = self.strategies[strategy].current_debt + assert current_debt >= assets_needed - return self._assess_share_of_unrealised_losses(strategy, assets_needed) + return self._assess_share_of_unrealised_losses(strategy, current_debt, assets_needed) ## Profit locking getter functions ## diff --git a/contracts/interfaces/IVault.sol b/contracts/interfaces/IVault.sol index 27e6b7c4..e45a343d 100644 --- a/contracts/interfaces/IVault.sol +++ b/contracts/interfaces/IVault.sol @@ -33,6 +33,7 @@ interface IVault is IERC4626 { address indexed strategy, uint256 new_debt ); + event UpdateAutoAllocate(bool auto_allocate); event UpdateDepositLimit(uint256 deposit_limit); event UpdateMinimumTotalIdle(uint256 minimum_total_idle); event UpdateProfitMaxUnlockTime(uint256 profit_max_unlock_time); @@ -54,6 +55,8 @@ interface IVault is IERC4626 { function use_default_queue() external view returns (bool); + function auto_allocate() external view returns (bool); + function minimum_total_idle() external view returns (uint256); function deposit_limit() external view returns (uint256); @@ -82,12 +85,18 @@ interface IVault is IERC4626 { uint256 ) external; + function setName(string memory) external; + + function setSymbol(string memory) external; + function set_accountant(address new_accountant) external; function set_default_queue(address[] memory new_default_queue) external; function set_use_default_queue(bool) external; + function set_auto_allocate(bool) external; + function set_deposit_limit(uint256 deposit_limit) external; function set_deposit_limit( diff --git a/contracts/interfaces/IVaultFactory.sol b/contracts/interfaces/IVaultFactory.sol index 56ea4025..880e4941 100644 --- a/contracts/interfaces/IVaultFactory.sol +++ b/contracts/interfaces/IVaultFactory.sol @@ -27,10 +27,6 @@ interface IVaultFactory { function name() external view returns (string memory); - function default_protocol_fee_config() external view returns (uint256); - - function custom_protocol_fee(address) external view returns (uint16); - function use_custom_protocol_fee(address) external view returns (bool); function deploy_new_vault( diff --git a/foundry.toml b/foundry.toml index 047eaac0..46acc547 100644 --- a/foundry.toml +++ b/foundry.toml @@ -4,17 +4,18 @@ test = 'foundry_tests' out = 'out' solc = "0.8.18" libs = ['lib'] +evm_version = "paris" remappings = [ 'forge-std/=lib/forge-std/src/', 'erc4626-tests/=lib/erc4626-tests/', - "@tokenized-strategy=contracts/.cache/tokenized-strategy/v3.0.2", - '@openzeppelin/contracts=contracts/.cache/openzeppelin/v4.9.5/', + "@tokenized-strategy=lib/tokenized-strategy/src", + '@openzeppelin/=lib/openzeppelin-contracts/', ] fs_permissions = [{ access = "read", path = "./"}] -match_contract = "VaultERC4626StdTest" -#match_path = "./foundry_tests/tests/*" +#match_contract = "VaultERC4626StdTest" +match_path = "./foundry_tests/tests/*" ffi = true [fuzz] diff --git a/foundry_tests/handlers/VaultHandler.sol b/foundry_tests/handlers/VaultHandler.sol new file mode 100644 index 00000000..19e67513 --- /dev/null +++ b/foundry_tests/handlers/VaultHandler.sol @@ -0,0 +1,291 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity >=0.8.18; + +import "forge-std/console.sol"; +import {ExtendedTest} from "../utils/ExtendedTest.sol"; +import {Setup, IVault, ERC20Mock, MockTokenizedStrategy} from "../utils/Setup.sol"; +import {LibAddressSet, AddressSet} from "../utils/LibAddressSet.sol"; + +contract VaultHandler is ExtendedTest { + using LibAddressSet for AddressSet; + + Setup public setup; + IVault public vault; + ERC20Mock public asset; + MockTokenizedStrategy public strategy; + + address public keeper; + + uint256 public maxFuzzAmount = 1e30; + uint256 public minFuzzAmount = 10_000; + + uint256 public ghost_depositSum; + uint256 public ghost_withdrawSum; + uint256 public ghost_debt; + uint256 public ghost_profitSum; + uint256 public ghost_lossSum; + uint256 public ghost_unreportedLossSum; + + uint256 public ghost_zeroDeposits; + uint256 public ghost_zeroWithdrawals; + uint256 public ghost_zeroTransfers; + uint256 public ghost_zeroTransferFroms; + + bool public unreported; + + mapping(bytes32 => uint256) public calls; + + AddressSet internal _actors; + address internal actor; + + modifier createActor() { + actor = msg.sender; + _actors.add(msg.sender); + _; + } + + modifier useActor(uint256 actorIndexSeed) { + actor = _actors.rand(actorIndexSeed); + _; + } + + modifier countCall(bytes32 key) { + calls[key]++; + _; + } + + constructor() { + setup = Setup(msg.sender); + + asset = setup.asset(); + vault = setup.vault(); + strategy = setup.strategy(); + keeper = setup.keeper(); + skip(10); + } + + function deposit(uint256 _amount) public createActor countCall("deposit") { + _amount = bound(_amount, minFuzzAmount, maxFuzzAmount); + + asset.mint(actor, _amount); + vm.prank(actor); + asset.approve(address(vault), _amount); + + vm.prank(actor); + vault.deposit(_amount, actor); + + ghost_depositSum += _amount; + } + + function mint(uint256 _amount) public createActor countCall("mint") { + _amount = bound(_amount, minFuzzAmount, maxFuzzAmount); + + uint256 toMint = vault.previewMint(_amount); + asset.mint(actor, toMint); + + vm.prank(actor); + asset.approve(address(vault), toMint); + + vm.prank(actor); + uint256 assets = vault.mint(_amount, actor); + + ghost_depositSum += assets; + } + + function withdraw( + uint256 actorSeed, + uint256 _amount + ) public useActor(actorSeed) countCall("withdraw") { + if (vault.maxWithdraw(address(actor)) == 0) { + unchecked { + deposit(_amount * 2); + } + } + _amount = bound(_amount, 0, vault.maxWithdraw(address(actor))); + if (_amount == 0) ghost_zeroWithdrawals++; + + uint256 idle = vault.totalIdle(); + + vm.prank(actor); + vault.withdraw(_amount, actor, actor, 0); + + ghost_withdrawSum += _amount; + if (_amount > idle) ghost_debt -= (_amount - idle); + } + + function redeem( + uint256 actorSeed, + uint256 _amount + ) public useActor(actorSeed) countCall("redeem") { + if (vault.balanceOf(address(actor)) == 0) { + unchecked { + mint(_amount * 2); + } + } + _amount = bound(_amount, 0, vault.balanceOf(address(actor))); + if (_amount == 0) ghost_zeroWithdrawals++; + + uint256 idle = vault.totalIdle(); + + vm.prank(actor); + uint256 assets = vault.redeem(_amount, actor, actor, 0); + + ghost_withdrawSum += assets; + if (assets > idle) ghost_debt -= (assets - idle); + } + + function updateDebt(uint256 _amount) public countCall("updateDebt") { + uint256 min = unreported ? ghost_debt : 0; + + _amount = bound(_amount, min, vault.totalAssets()); + + vm.prank(keeper); + uint256 newDebt = vault.update_debt(address(strategy), _amount); + + ghost_debt = newDebt; + } + + function reportProfit(uint256 _amount) public countCall("reportProfit") { + _amount = bound(_amount, 1_000, strategy.totalAssets() / 2); + + // Simulate earning interest + asset.mint(address(strategy), _amount); + + vm.prank(keeper); + strategy.report(); + + vm.prank(keeper); + (uint256 profit, uint256 loss) = vault.process_report( + address(strategy) + ); + + ghost_profitSum += profit; + ghost_lossSum += loss; + ghost_debt += profit; + ghost_debt -= loss; + unreported = false; + } + + function reportLoss(uint256 _amount) public countCall("reportLoss") { + _amount = bound(_amount, 0, strategy.totalAssets() / 2); + + // Simulate losing money + vm.prank(address(strategy)); + asset.transfer(address(69), _amount); + + vm.prank(keeper); + strategy.report(); + + vm.prank(keeper); + (uint256 profit, uint256 loss) = vault.process_report( + address(strategy) + ); + + ghost_profitSum += profit; + ghost_lossSum += loss; + ghost_debt += profit; + ghost_debt -= loss; + unreported = false; + } + + function approve( + uint256 actorSeed, + uint256 spenderSeed, + uint256 amount + ) public useActor(actorSeed) countCall("approve") { + address spender = _actors.rand(spenderSeed); + + vm.prank(actor); + vault.approve(spender, amount); + } + + function transfer( + uint256 actorSeed, + uint256 toSeed, + uint256 amount + ) public useActor(actorSeed) countCall("transfer") { + address to = _actors.rand(toSeed); + + amount = bound(amount, 0, vault.balanceOf(actor)); + if (amount == 0) ghost_zeroTransfers++; + + vm.prank(actor); + vault.transfer(to, amount); + } + + function transferFrom( + uint256 actorSeed, + uint256 fromSeed, + uint256 amount + ) public useActor(actorSeed) countCall("transferFrom") { + address from = _actors.rand(fromSeed); + address to = msg.sender; + _actors.add(msg.sender); + + amount = bound(amount, 0, vault.balanceOf(from)); + uint256 allowance = vault.allowance(actor, from); + if (allowance != 0) { + vm.prank(from); + vault.approve(actor, 0); + } + + vm.prank(from); + vault.approve(actor, amount); + + if (amount == 0) ghost_zeroTransferFroms++; + + vm.prank(actor); + vault.transferFrom(from, to, amount); + } + + function unreportedLoss( + uint256 _amount + ) public countCall("unreportedLoss") { + _amount = bound(_amount, 0, strategy.totalAssets() / 10); + + // Simulate losing money + vm.prank(address(strategy)); + asset.transfer(address(69), _amount); + + vm.prank(keeper); + strategy.report(); + + ghost_unreportedLossSum += _amount; + unreported = true; + } + + function increaseTime() public countCall("skip") { + skip(1 days); + } + + function callSummary() external view { + console.log("Call summary:"); + console.log("-------------------"); + console.log("deposit", calls["deposit"]); + console.log("mint", calls["mint"]); + console.log("withdraw", calls["withdraw"]); + console.log("redeem", calls["redeem"]); + console.log("debt updates", calls["debtUpdate"]); + console.log("report profit", calls["reportProfit"]); + console.log("report loss", calls["reportLoss"]); + console.log("tend", calls["tend"]); + console.log("approve", calls["approve"]); + console.log("transfer", calls["transfer"]); + console.log("transferFrom", calls["transferFrom"]); + console.log("skip", calls["skip"]); + console.log("unreportedLoss", calls["unreportedLoss"]); + console.log("-------------------"); + console.log("Total Deposit sum", ghost_depositSum); + console.log("Total withdraw sum", ghost_withdrawSum); + console.log("Current Debt", ghost_debt); + console.log("Total Profit", ghost_profitSum); + console.log("Total Loss", ghost_lossSum); + console.log("Total unreported Loss", ghost_unreportedLossSum); + console.log("-------------------"); + console.log("Amount of actors", _actors.count()); + console.log("Zero Deposits:", ghost_zeroDeposits); + console.log("Zero withdrawals:", ghost_zeroWithdrawals); + console.log("Zero transferFroms:", ghost_zeroTransferFroms); + console.log("Zero transfers:", ghost_zeroTransfers); + } +} diff --git a/foundry_tests/tests/ERC4626Std.t.sol b/foundry_tests/tests/ERC4626Std.t.sol index 7d7162ef..c3de553a 100644 --- a/foundry_tests/tests/ERC4626Std.t.sol +++ b/foundry_tests/tests/ERC4626Std.t.sol @@ -29,6 +29,24 @@ contract VaultERC4626StdTest is ERC4626Test, Setup { super.test_maxRedeem(init); } + //Avoid special case for deposits of uint256 max + function test_previewDeposit( + Init memory init, + uint assets + ) public override { + if (assets == type(uint256).max) assets -= 1; + super.test_previewDeposit(init, assets); + } + + function test_deposit( + Init memory init, + uint assets, + uint allowance + ) public override { + if (assets == type(uint256).max) assets -= 1; + super.test_deposit(init, assets, allowance); + } + function clamp( Init memory init, uint max diff --git a/foundry_tests/tests/Invariants.t.sol b/foundry_tests/tests/Invariants.t.sol new file mode 100644 index 00000000..1c4cf968 --- /dev/null +++ b/foundry_tests/tests/Invariants.t.sol @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.18; + +import "forge-std/console.sol"; +import {BaseInvariant} from "../utils/BaseInvariant.sol"; +import {VaultHandler} from "../handlers/VaultHandler.sol"; + +contract VaultInvariantTest is BaseInvariant { + VaultHandler public vaultHandler; + + function setUp() public override { + super.setUp(); + + vaultHandler = new VaultHandler(); + + excludeSender(address(0)); + excludeSender(address(vault)); + excludeSender(address(asset)); + excludeSender(address(strategy)); + + targetContract(address(vaultHandler)); + + targetSelector( + FuzzSelector({ + addr: address(vaultHandler), + selectors: getTargetSelectors() + }) + ); + } + + function invariant_totalAssets() public { + assert_totalAssets( + vaultHandler.ghost_depositSum(), + vaultHandler.ghost_withdrawSum(), + vaultHandler.ghost_profitSum(), + vaultHandler.ghost_lossSum() + ); + } + + function invariant_maxWithdraw() public { + assert_maxWithdraw(vaultHandler.unreported()); + } + + function invariant_maxRedeem() public { + assert_maxRedeem(vaultHandler.unreported()); + } + + function invariant_maxWithdrawEqualsMaxRedeem() public { + assert_maxRedeemEqualsMaxWithdraw(vaultHandler.unreported()); + } + + function invariant_unlockingTime() public { + assert_unlockingTime(); + } + + function invariant_unlockedShares() public { + assert_unlockedShares(); + } + + function invariant_previewMintAndConvertToAssets() public { + assert_previewMintAndConvertToAssets(); + } + + function invariant_previewWithdrawAndConvertToShares() public { + assert_previewWithdrawAndConvertToShares(); + } + + function invariant_balanceAndTotalAssets() public { + assert_balanceAndTotalAssets(vaultHandler.unreported()); + } + + function invariant_totalDebt() public { + assert_totalDebt(vaultHandler.unreported()); + } + + function invariant_callSummary() public view { + vaultHandler.callSummary(); + } + + function getTargetSelectors() + internal + view + returns (bytes4[] memory selectors) + { + selectors = new bytes4[](12); + selectors[0] = vaultHandler.deposit.selector; + selectors[1] = vaultHandler.withdraw.selector; + selectors[2] = vaultHandler.mint.selector; + selectors[3] = vaultHandler.redeem.selector; + selectors[4] = vaultHandler.reportProfit.selector; + selectors[5] = vaultHandler.reportLoss.selector; + selectors[6] = vaultHandler.unreportedLoss.selector; + selectors[7] = vaultHandler.approve.selector; + selectors[8] = vaultHandler.transfer.selector; + selectors[9] = vaultHandler.transferFrom.selector; + selectors[10] = vaultHandler.increaseTime.selector; + selectors[11] = vaultHandler.updateDebt.selector; + } +} diff --git a/foundry_tests/utils/BaseInvariant.sol b/foundry_tests/utils/BaseInvariant.sol new file mode 100644 index 00000000..4bf3adb2 --- /dev/null +++ b/foundry_tests/utils/BaseInvariant.sol @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.18; + +import "forge-std/console.sol"; +import {Setup} from "./Setup.sol"; + +abstract contract BaseInvariant is Setup { + function setUp() public virtual override { + super.setUp(); + } + + function assert_totalAssets( + uint256 _totalDeposits, + uint256 _totalWithdraw, + uint256 _totalGain, + uint256 _totalLosses + ) public { + assertEq( + vault.totalAssets(), + _totalDeposits + _totalGain - _totalWithdraw - _totalLosses + ); + assertEq(vault.totalAssets(), vault.totalDebt() + vault.totalIdle()); + } + + function assert_maxWithdraw(bool unreportedLoss) public { + if (unreportedLoss) { + // withdraw would revert with unreported loss so maxWithdraw is totalIdle + assertLe(vault.maxWithdraw(msg.sender), vault.totalIdle()); + } else { + assertLe(vault.maxWithdraw(msg.sender), vault.totalAssets()); + } + assertLe(vault.maxWithdraw(msg.sender, 10_000), vault.totalAssets()); + } + + function assert_maxRedeem(bool unreportedLoss) public { + assertLe(vault.maxRedeem(msg.sender), vault.totalSupply()); + assertLe(vault.maxRedeem(msg.sender), vault.balanceOf(msg.sender)); + if (unreportedLoss) { + assertLe(vault.maxRedeem(msg.sender, 0), vault.totalIdle()); + } else { + assertLe(vault.maxRedeem(msg.sender, 0), vault.totalSupply()); + assertLe( + vault.maxRedeem(msg.sender, 0), + vault.balanceOf(msg.sender) + ); + } + } + + function assert_maxRedeemEqualsMaxWithdraw(bool unreportedLoss) public { + if (unreportedLoss) { + assertApproxEq( + vault.maxWithdraw(msg.sender, 10_000), + vault.convertToAssets(vault.maxRedeem(msg.sender)), + 3 + ); + assertApproxEq( + vault.maxRedeem(msg.sender), + vault.convertToShares(vault.maxWithdraw(msg.sender, 10_000)), + 3 + ); + } else { + assertApproxEq( + vault.maxWithdraw(msg.sender), + vault.convertToAssets(vault.maxRedeem(msg.sender)), + 3 + ); + assertApproxEq( + vault.maxRedeem(msg.sender), + vault.convertToShares(vault.maxWithdraw(msg.sender)), + 3 + ); + } + } + + function assert_unlockingTime() public { + uint256 unlockingDate = vault.fullProfitUnlockDate(); + uint256 balance = vault.balanceOf(address(vault)); + uint256 unlockedShares = vault.unlockedShares(); + if (unlockingDate != 0 && vault.profitUnlockingRate() > 0) { + if ( + block.timestamp == + vault.strategies(address(strategy)).last_report + ) { + assertEq(unlockedShares, 0); + assertGt(balance, 0); + } else if (block.timestamp < unlockingDate) { + assertGt(unlockedShares, 0); + assertGt(balance, 0); + } else { + // We should have unlocked full balance + assertEq(balance, 0); + assertGt(unlockedShares, 0); + } + } else { + assertEq(balance, 0); + } + } + + function assert_unlockedShares() public { + uint256 unlockedShares = vault.unlockedShares(); + uint256 fullBalance = vault.balanceOf(address(vault)) + unlockedShares; + uint256 unlockingDate = vault.fullProfitUnlockDate(); + if ( + unlockingDate != 0 && + vault.profitUnlockingRate() > 0 && + block.timestamp < unlockingDate + ) { + assertLt(unlockedShares, fullBalance); + } else { + assertEq(unlockedShares, fullBalance); + assertEq(vault.balanceOf(address(vault)), 0); + } + } + + function assert_previewMintAndConvertToAssets() public { + assertApproxEq(vault.previewMint(WAD), vault.convertToAssets(WAD), 1); + } + + function assert_previewWithdrawAndConvertToShares() public { + assertApproxEq( + vault.previewWithdraw(WAD), + vault.convertToShares(WAD), + 1 + ); + } + + function assert_balanceAndTotalAssets(bool unreported) public { + if (!unreported) { + assertLe( + vault.totalAssets(), + asset.balanceOf(address(strategy)) + + asset.balanceOf(address(vault)) + ); + } + assertEq(vault.totalIdle(), asset.balanceOf(address(vault))); + } + + function assert_totalDebt(bool unreported) public { + uint256 currentDebt = vault.strategies(address(strategy)).current_debt; + assertEq(vault.totalDebt(), currentDebt); + if (!unreported) { + assertGe(asset.balanceOf(address(strategy)), currentDebt); + } + } +} diff --git a/foundry_tests/utils/LibAddressSet.sol b/foundry_tests/utils/LibAddressSet.sol new file mode 100644 index 00000000..b0b17df1 --- /dev/null +++ b/foundry_tests/utils/LibAddressSet.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity >=0.8.18; + +struct AddressSet { + address[] addrs; + mapping(address => bool) saved; +} + +library LibAddressSet { + function add(AddressSet storage s, address addr) internal { + if (!s.saved[addr]) { + s.addrs.push(addr); + s.saved[addr] = true; + } + } + + function contains( + AddressSet storage s, + address addr + ) internal view returns (bool) { + return s.saved[addr]; + } + + function count(AddressSet storage s) internal view returns (uint256) { + return s.addrs.length; + } + + function rand( + AddressSet storage s, + uint256 seed + ) internal view returns (address) { + if (s.addrs.length > 0) { + return s.addrs[seed % s.addrs.length]; + } else { + return address(0); + } + } + + function addresses( + AddressSet storage s + ) internal view returns (address[] memory _addrs) { + return s.addrs; + } +} diff --git a/foundry_tests/utils/Setup.sol b/foundry_tests/utils/Setup.sol index c45d1c19..e986786e 100644 --- a/foundry_tests/utils/Setup.sol +++ b/foundry_tests/utils/Setup.sol @@ -10,6 +10,8 @@ import {IVault} from "../../contracts/interfaces/IVault.sol"; import {Roles} from "../../contracts/interfaces/Roles.sol"; import {IVaultFactory} from "../../contracts/interfaces/IVaultFactory.sol"; +import {MockTokenizedStrategy} from "../../contracts/test/mocks/ERC4626/MockTokenizedStrategy.sol"; + import {VyperDeployer} from "./VyperDeployer.sol"; contract Setup is ExtendedTest { @@ -18,11 +20,16 @@ contract Setup is ExtendedTest { IVaultFactory public vaultFactory; VyperDeployer public vyperDeployer; + MockTokenizedStrategy public strategy; + address public daddy = address(69); address public vaultManagement = address(2); + address public keeper = address(32); uint256 public maxFuzzAmount = 1e30; + uint256 public WAD = 1e18; + function setUp() public virtual { vyperDeployer = new VyperDeployer(); @@ -32,6 +39,8 @@ contract Setup is ExtendedTest { vault = IVault(setUpVault()); + strategy = MockTokenizedStrategy(setUpStrategy()); + vm.label(address(vault), "Vault"); vm.label(address(asset), "Asset"); vm.label(address(vaultFactory), "Vault Factory"); @@ -67,9 +76,33 @@ contract Setup is ExtendedTest { // Give the vault manager all the roles _vault.set_role(vaultManagement, Roles.ALL); + vm.prank(daddy); + _vault.set_role(keeper, Roles.REPORTING_MANAGER | Roles.DEBT_MANAGER); + vm.prank(vaultManagement); _vault.set_deposit_limit(type(uint256).max); return _vault; } + + function setUpStrategy() public returns (MockTokenizedStrategy _strategy) { + _strategy = new MockTokenizedStrategy( + address(vaultFactory), + address(asset), + "Test Strategy", + vaultManagement, + keeper + ); + + vm.startPrank(vaultManagement); + + vault.add_strategy(address(_strategy)); + + vault.update_max_debt_for_strategy( + address(_strategy), + type(uint256).max + ); + + vm.stopPrank(); + } } diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts new file mode 160000 index 00000000..bd325d56 --- /dev/null +++ b/lib/openzeppelin-contracts @@ -0,0 +1 @@ +Subproject commit bd325d56b4c62c9c5c1aff048c37c6bb18ac0290 diff --git a/lib/tokenized-strategy b/lib/tokenized-strategy new file mode 160000 index 00000000..0d90dee1 --- /dev/null +++ b/lib/tokenized-strategy @@ -0,0 +1 @@ +Subproject commit 0d90dee170d53a0e04af3ff41d2f7a4f3ac395bd diff --git a/requirements.txt b/requirements.txt index f0bc9548..5edc3060 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ black==22.3.0 -eth-ape>=0.7.0 -vyper==0.3.7 \ No newline at end of file +eth-ape==0.8.10 +vyper==0.3.7 +eth-typing==3.5.2 \ No newline at end of file diff --git a/scripts/deploy.py b/scripts/deploy.py index fd3d65d9..391822a7 100644 --- a/scripts/deploy.py +++ b/scripts/deploy.py @@ -19,7 +19,7 @@ def deploy_original_and_factory(): "0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5Ed" ) - salt_string = "v3.0.2" + salt_string = "v3.0.3" # Create a SHA-256 hash object hash_object = hashlib.sha256() @@ -51,10 +51,12 @@ def deploy_original_and_factory(): # deploy factory print(f"Deploying factory...") + init_gov = "0x6f3cBE2ab3483EC4BA7B672fbdCa0E9B33F88db8" + factory_constructor = vault_factory.constructor.encode_input( - "Yearn v3.0.2 Vault Factory", + "Yearn v3.0.3 Vault Factory", original_address, - "0x33333333D5eFb92f19a5F94a43456b3cec2797AE", + init_gov, ) factory_deploy_bytecode = HexBytes( diff --git a/tests/unit/factory/test_ownership.py b/tests/unit/factory/test_ownership.py index 9de28d00..efdebbe5 100644 --- a/tests/unit/factory/test_ownership.py +++ b/tests/unit/factory/test_ownership.py @@ -5,66 +5,66 @@ def test_gov_transfers_ownership(vault_factory, gov, strategist): assert vault_factory.governance() == gov - assert vault_factory.pending_governance() == ZERO_ADDRESS + assert vault_factory.pendingGovernance() == ZERO_ADDRESS - vault_factory.set_governance(strategist, sender=gov) + vault_factory.transferGovernance(strategist, sender=gov) assert vault_factory.governance() == gov - assert vault_factory.pending_governance() == strategist + assert vault_factory.pendingGovernance() == strategist - vault_factory.accept_governance(sender=strategist) + vault_factory.acceptGovernance(sender=strategist) assert vault_factory.governance() == strategist - assert vault_factory.pending_governance() == ZERO_ADDRESS + assert vault_factory.pendingGovernance() == ZERO_ADDRESS def test_gov_transfers_ownership__gov_cant_accept(vault_factory, gov, strategist): assert vault_factory.governance() == gov - assert vault_factory.pending_governance() == ZERO_ADDRESS + assert vault_factory.pendingGovernance() == ZERO_ADDRESS - vault_factory.set_governance(strategist, sender=gov) + vault_factory.transferGovernance(strategist, sender=gov) assert vault_factory.governance() == gov - assert vault_factory.pending_governance() == strategist + assert vault_factory.pendingGovernance() == strategist with ape.reverts("not pending governance"): - vault_factory.accept_governance(sender=gov) + vault_factory.acceptGovernance(sender=gov) assert vault_factory.governance() == gov - assert vault_factory.pending_governance() == strategist + assert vault_factory.pendingGovernance() == strategist def test_random_transfers_ownership__fails(vault_factory, gov, strategist): assert vault_factory.governance() == gov - assert vault_factory.pending_governance() == ZERO_ADDRESS + assert vault_factory.pendingGovernance() == ZERO_ADDRESS with ape.reverts("not governance"): - vault_factory.set_governance(strategist, sender=strategist) + vault_factory.transferGovernance(strategist, sender=strategist) assert vault_factory.governance() == gov - assert vault_factory.pending_governance() == ZERO_ADDRESS + assert vault_factory.pendingGovernance() == ZERO_ADDRESS def test_gov_transfers_ownership__can_change_pending( vault_factory, gov, bunny, strategist ): assert vault_factory.governance() == gov - assert vault_factory.pending_governance() == ZERO_ADDRESS + assert vault_factory.pendingGovernance() == ZERO_ADDRESS - vault_factory.set_governance(strategist, sender=gov) + vault_factory.transferGovernance(strategist, sender=gov) assert vault_factory.governance() == gov - assert vault_factory.pending_governance() == strategist + assert vault_factory.pendingGovernance() == strategist - vault_factory.set_governance(bunny, sender=gov) + vault_factory.transferGovernance(bunny, sender=gov) assert vault_factory.governance() == gov - assert vault_factory.pending_governance() == bunny + assert vault_factory.pendingGovernance() == bunny with ape.reverts("not pending governance"): - vault_factory.accept_governance(sender=strategist) + vault_factory.acceptGovernance(sender=strategist) - vault_factory.accept_governance(sender=bunny) + vault_factory.acceptGovernance(sender=bunny) assert vault_factory.governance() == bunny - assert vault_factory.pending_governance() == ZERO_ADDRESS + assert vault_factory.pendingGovernance() == ZERO_ADDRESS diff --git a/tests/unit/factory/test_protocol_fees_config.py b/tests/unit/factory/test_protocol_fees_config.py index 479c7fe5..43a5194a 100644 --- a/tests/unit/factory/test_protocol_fees_config.py +++ b/tests/unit/factory/test_protocol_fees_config.py @@ -10,7 +10,7 @@ def test__set_protocol_fee_recipient(gov, vault_factory): assert event[0].old_fee_recipient == ZERO_ADDRESS assert event[0].new_fee_recipient == gov.address - assert vault_factory.protocol_fee_config().fee_recipient == gov.address + assert vault_factory.protocol_fee_config()[1] == gov.address def test__set_protocol_fee_recipient__zero_address__reverts(gov, vault_factory): @@ -19,7 +19,7 @@ def test__set_protocol_fee_recipient__zero_address__reverts(gov, vault_factory): def test__set_protocol_fees(gov, vault_factory): - assert vault_factory.protocol_fee_config().fee_bps == 0 + assert vault_factory.protocol_fee_config()[0] == 0 # Need to set the fee recipient first vault_factory.set_protocol_fee_recipient(gov.address, sender=gov) @@ -30,26 +30,20 @@ def test__set_protocol_fees(gov, vault_factory): assert event[0].old_fee_bps == 0 assert event[0].new_fee_bps == 20 - assert vault_factory.protocol_fee_config().fee_bps == 20 + assert vault_factory.protocol_fee_config()[0] == 20 def test__set_custom_protocol_fee(gov, vault_factory, create_vault, asset): # Set the default protocol fee recipient vault_factory.set_protocol_fee_recipient(gov.address, sender=gov) - assert vault_factory.protocol_fee_config().fee_recipient == gov.address - assert vault_factory.protocol_fee_config().fee_bps == 0 + assert vault_factory.protocol_fee_config() == (0, gov.address) vault = create_vault(asset) # Make sure its currently set to the default settings. - assert ( - vault_factory.protocol_fee_config(sender=vault.address).fee_recipient - == gov.address - ) - assert vault_factory.protocol_fee_config(sender=vault.address).fee_bps == 0 - assert vault_factory.protocol_fee_config(vault.address).fee_recipient == gov.address - assert vault_factory.protocol_fee_config(vault.address).fee_bps == 0 + assert vault_factory.protocol_fee_config(vault.address) == (0, gov.address) + assert vault_factory.protocol_fee_config(sender=vault.address) == (0, gov.address) new_fee = int(20) # Set custom fee for new vault. @@ -62,20 +56,17 @@ def test__set_custom_protocol_fee(gov, vault_factory, create_vault, asset): assert event[0].new_custom_protocol_fee == new_fee assert vault_factory.use_custom_protocol_fee(vault.address) == True - assert vault_factory.custom_protocol_fee(vault.address) == new_fee + assert vault_factory.protocol_fee_config(vault.address)[0] == new_fee # Should now be different than default - assert ( - vault_factory.protocol_fee_config(sender=vault.address).fee_recipient - == gov.address + assert vault_factory.protocol_fee_config(vault.address) == (new_fee, gov.address) + assert vault_factory.protocol_fee_config(sender=vault.address) == ( + new_fee, + gov.address, ) - assert vault_factory.protocol_fee_config(sender=vault.address).fee_bps == new_fee - assert vault_factory.protocol_fee_config(vault.address).fee_recipient == gov.address - assert vault_factory.protocol_fee_config(vault.address).fee_bps == new_fee # Make sure the default is not changed. - assert vault_factory.protocol_fee_config().fee_recipient == gov.address - assert vault_factory.protocol_fee_config().fee_bps == 0 + assert vault_factory.protocol_fee_config() == (0, gov.address) def test__remove_custom_protocol_fee(gov, vault_factory, create_vault, asset): @@ -98,13 +89,11 @@ def test__remove_custom_protocol_fee(gov, vault_factory, create_vault, asset): assert event[0].new_custom_protocol_fee == new_fee # Should now be different than default - assert ( - vault_factory.protocol_fee_config(sender=vault.address).fee_recipient - == gov.address + assert vault_factory.protocol_fee_config(vault.address) == (new_fee, gov.address) + assert vault_factory.protocol_fee_config(sender=vault.address) == ( + new_fee, + gov.address, ) - assert vault_factory.protocol_fee_config(sender=vault.address).fee_bps == new_fee - assert vault_factory.protocol_fee_config(vault.address).fee_recipient == gov.address - assert vault_factory.protocol_fee_config(vault.address).fee_bps == new_fee # Now remove the custom fee config tx = vault_factory.remove_custom_protocol_fee(vault.address, sender=gov) @@ -115,29 +104,27 @@ def test__remove_custom_protocol_fee(gov, vault_factory, create_vault, asset): assert event[0].vault == vault.address # Should now be the default - assert ( - vault_factory.protocol_fee_config(sender=vault.address).fee_recipient - == gov.address + assert vault_factory.protocol_fee_config(vault.address) == ( + generic_fee, + gov.address, ) - assert ( - vault_factory.protocol_fee_config(sender=vault.address).fee_bps == generic_fee + assert vault_factory.protocol_fee_config(sender=vault.address) == ( + generic_fee, + gov.address, ) - assert vault_factory.protocol_fee_config(vault.address).fee_recipient == gov.address - assert vault_factory.protocol_fee_config(vault.address).fee_bps == generic_fee assert vault_factory.use_custom_protocol_fee(vault.address) == False - assert vault_factory.custom_protocol_fee(vault.address) == 0 def test__set_protocol_fee_before_recipient__reverts(gov, vault_factory): - assert vault_factory.protocol_fee_config().fee_recipient == ZERO_ADDRESS + assert vault_factory.protocol_fee_config()[1] == ZERO_ADDRESS with ape.reverts("no recipient"): vault_factory.set_protocol_fee_bps(20, sender=gov) def test__set_custom_fee_before_recipient__reverts(gov, vault_factory, vault): - assert vault_factory.protocol_fee_config().fee_recipient == ZERO_ADDRESS + assert vault_factory.protocol_fee_config()[1] == ZERO_ADDRESS with ape.reverts("no recipient"): vault_factory.set_custom_protocol_fee_bps(vault.address, 20, sender=gov) diff --git a/tests/unit/vault/test_auto_allocate.py b/tests/unit/vault/test_auto_allocate.py new file mode 100644 index 00000000..39b6c1dd --- /dev/null +++ b/tests/unit/vault/test_auto_allocate.py @@ -0,0 +1,437 @@ +import ape +import pytest +from utils.constants import DAY + + +def test_deposit__auto_update_debt( + asset, fish, fish_amount, gov, vault, strategy, user_deposit +): + assets = fish_amount + + assert vault.auto_allocate() == False + + vault.set_auto_allocate(True, sender=gov) + vault.update_max_debt_for_strategy(strategy, assets * 2, sender=gov) + + assert vault.auto_allocate() + assert strategy.maxDeposit(vault) > assets + assert vault.strategies(strategy)["max_debt"] > assets + assert vault.minimum_total_idle() == 0 + + assert vault.totalAssets() == 0 + assert vault.totalIdle() == 0 + assert vault.totalDebt() == 0 + assert strategy.totalAssets() == 0 + assert strategy.balanceOf(vault) == 0 + assert vault.strategies(strategy)["current_debt"] == 0 + assert vault.balanceOf(fish) == 0 + + asset.approve(vault, assets, sender=fish) + + tx = vault.deposit(assets, fish, sender=fish) + + event = tx.decode_logs(vault.DebtUpdated) + + assert len(event) == 1 + event = event[0] + + assert event.strategy == strategy + assert event.current_debt == 0 + assert event.new_debt == assets + + assert vault.totalAssets() == assets + assert vault.totalIdle() == 0 + assert vault.totalDebt() == assets + assert strategy.totalAssets() == assets + assert strategy.balanceOf(vault) == assets + assert vault.strategies(strategy)["current_debt"] == assets + assert vault.balanceOf(fish) == assets + + +def test_mint__auto_update_debt( + asset, fish, fish_amount, gov, vault, strategy, user_deposit +): + assets = fish_amount + + assert vault.auto_allocate() == False + + vault.set_auto_allocate(True, sender=gov) + vault.update_max_debt_for_strategy(strategy, assets * 2, sender=gov) + + assert vault.auto_allocate() + assert strategy.maxDeposit(vault) > assets + assert vault.strategies(strategy)["max_debt"] > assets + assert vault.minimum_total_idle() == 0 + + assert vault.totalAssets() == 0 + assert vault.totalIdle() == 0 + assert vault.totalDebt() == 0 + assert strategy.totalAssets() == 0 + assert strategy.balanceOf(vault) == 0 + assert vault.strategies(strategy)["current_debt"] == 0 + assert vault.balanceOf(fish) == 0 + + asset.approve(vault, assets, sender=fish) + + tx = vault.mint(assets, fish, sender=fish) + + event = tx.decode_logs(vault.DebtUpdated) + + assert len(event) == 1 + event = event[0] + + assert event.strategy == strategy + assert event.current_debt == 0 + assert event.new_debt == assets + + assert vault.totalAssets() == assets + assert vault.totalIdle() == 0 + assert vault.totalDebt() == assets + assert strategy.totalAssets() == assets + assert strategy.balanceOf(vault) == assets + assert vault.strategies(strategy)["current_debt"] == assets + assert vault.balanceOf(fish) == assets + + +def test_deposit__auto_update_debt__max_debt( + asset, fish, fish_amount, gov, vault, strategy, user_deposit +): + assets = fish_amount + max_debt = assets // 10 + + assert vault.auto_allocate() == False + + vault.set_auto_allocate(True, sender=gov) + vault.update_max_debt_for_strategy(strategy, max_debt, sender=gov) + + assert vault.auto_allocate() + assert strategy.maxDeposit(vault) > assets + assert vault.strategies(strategy)["max_debt"] < assets + assert vault.minimum_total_idle() == 0 + + assert vault.totalAssets() == 0 + assert vault.totalIdle() == 0 + assert vault.totalDebt() == 0 + assert strategy.totalAssets() == 0 + assert strategy.balanceOf(vault) == 0 + assert vault.strategies(strategy)["current_debt"] == 0 + assert vault.balanceOf(fish) == 0 + + asset.approve(vault, assets, sender=fish) + + tx = vault.deposit(assets, fish, sender=fish) + + event = tx.decode_logs(vault.DebtUpdated) + + assert len(event) == 1 + event = event[0] + + assert event.strategy == strategy + assert event.current_debt == 0 + assert event.new_debt == max_debt + + assert vault.totalAssets() == assets + assert vault.totalIdle() == assets - max_debt + assert vault.totalDebt() == max_debt + assert strategy.totalAssets() == max_debt + assert strategy.balanceOf(vault) == max_debt + assert vault.strategies(strategy)["current_debt"] == max_debt + assert vault.balanceOf(fish) == assets + + +def test_deposit__auto_update_debt__max_deposit( + asset, fish, fish_amount, gov, vault, strategy, user_deposit +): + assets = fish_amount + max_deposit = assets // 10 + + assert vault.auto_allocate() == False + + vault.set_auto_allocate(True, sender=gov) + vault.update_max_debt_for_strategy(strategy, 2**256 - 1, sender=gov) + strategy.setMaxDebt(max_deposit, sender=gov) + + assert vault.auto_allocate() + assert strategy.maxDeposit(vault) == max_deposit + assert vault.strategies(strategy)["max_debt"] > assets + assert vault.minimum_total_idle() == 0 + + assert vault.totalAssets() == 0 + assert vault.totalIdle() == 0 + assert vault.totalDebt() == 0 + assert strategy.totalAssets() == 0 + assert strategy.balanceOf(vault) == 0 + assert vault.strategies(strategy)["current_debt"] == 0 + assert vault.balanceOf(fish) == 0 + + asset.approve(vault, assets, sender=fish) + + tx = vault.deposit(assets, fish, sender=fish) + + event = tx.decode_logs(vault.DebtUpdated) + + assert len(event) == 1 + event = event[0] + + assert event.strategy == strategy + assert event.current_debt == 0 + assert event.new_debt == max_deposit + + assert vault.totalAssets() == assets + assert vault.totalIdle() == assets - max_deposit + assert vault.totalDebt() == max_deposit + assert strategy.totalAssets() == max_deposit + assert strategy.balanceOf(vault) == max_deposit + assert vault.strategies(strategy)["current_debt"] == max_deposit + assert vault.balanceOf(fish) == assets + + +def test_deposit__auto_update_debt__max_deposit_zero( + asset, fish, fish_amount, gov, vault, strategy, user_deposit +): + assets = fish_amount + max_deposit = 0 + + assert vault.auto_allocate() == False + + vault.set_auto_allocate(True, sender=gov) + vault.update_max_debt_for_strategy(strategy, 2**256 - 1, sender=gov) + strategy.setMaxDebt(max_deposit, sender=gov) + + assert vault.auto_allocate() + assert strategy.maxDeposit(vault) == max_deposit + assert vault.strategies(strategy)["max_debt"] > assets + assert vault.minimum_total_idle() == 0 + + assert vault.totalAssets() == 0 + assert vault.totalIdle() == 0 + assert vault.totalDebt() == 0 + assert strategy.totalAssets() == 0 + assert strategy.balanceOf(vault) == 0 + assert vault.strategies(strategy)["current_debt"] == 0 + assert vault.balanceOf(fish) == 0 + + asset.approve(vault, assets, sender=fish) + + tx = vault.deposit(assets, fish, sender=fish) + + event = tx.decode_logs(vault.DebtUpdated) + + assert len(event) == 0 + + assert vault.totalAssets() == assets + assert vault.totalIdle() == assets - max_deposit + assert vault.totalDebt() == max_deposit + assert strategy.totalAssets() == max_deposit + assert strategy.balanceOf(vault) == max_deposit + assert vault.strategies(strategy)["current_debt"] == max_deposit + assert vault.balanceOf(fish) == assets + + +def test_deposit__auto_update_debt__min_idle( + asset, fish, fish_amount, gov, vault, strategy, user_deposit +): + assets = fish_amount + min_idle = assets // 10 + + assert vault.auto_allocate() == False + + vault.set_auto_allocate(True, sender=gov) + vault.update_max_debt_for_strategy(strategy, 2**256 - 1, sender=gov) + vault.set_minimum_total_idle(min_idle, sender=gov) + + assert vault.auto_allocate() + assert strategy.maxDeposit(vault) > assets + assert vault.strategies(strategy)["max_debt"] > assets + assert vault.minimum_total_idle() == min_idle + + assert vault.totalAssets() == 0 + assert vault.totalIdle() == 0 + assert vault.totalDebt() == 0 + assert strategy.totalAssets() == 0 + assert strategy.balanceOf(vault) == 0 + assert vault.strategies(strategy)["current_debt"] == 0 + assert vault.balanceOf(fish) == 0 + + asset.approve(vault, assets, sender=fish) + + tx = vault.deposit(assets, fish, sender=fish) + + event = tx.decode_logs(vault.DebtUpdated) + + assert len(event) == 1 + event = event[0] + + assert event.strategy == strategy + assert event.current_debt == 0 + assert event.new_debt == assets - min_idle + + assert vault.totalAssets() == assets + assert vault.totalIdle() == min_idle + assert vault.totalDebt() == assets - min_idle + assert strategy.totalAssets() == assets - min_idle + assert strategy.balanceOf(vault) == assets - min_idle + assert vault.strategies(strategy)["current_debt"] == assets - min_idle + assert vault.balanceOf(fish) == assets + + +def test_deposit__auto_update_debt__min_idle( + asset, fish, fish_amount, gov, vault, strategy, user_deposit +): + assets = fish_amount + min_idle = assets // 10 + + assert vault.auto_allocate() == False + + vault.set_auto_allocate(True, sender=gov) + vault.update_max_debt_for_strategy(strategy, 2**256 - 1, sender=gov) + vault.set_minimum_total_idle(min_idle, sender=gov) + + assert vault.auto_allocate() + assert strategy.maxDeposit(vault) > assets + assert vault.strategies(strategy)["max_debt"] > assets + assert vault.minimum_total_idle() == min_idle + + assert vault.totalAssets() == 0 + assert vault.totalIdle() == 0 + assert vault.totalDebt() == 0 + assert strategy.totalAssets() == 0 + assert strategy.balanceOf(vault) == 0 + assert vault.strategies(strategy)["current_debt"] == 0 + assert vault.balanceOf(fish) == 0 + + asset.approve(vault, assets, sender=fish) + + tx = vault.deposit(assets, fish, sender=fish) + + event = tx.decode_logs(vault.DebtUpdated) + + assert len(event) == 1 + event = event[0] + + assert event.strategy == strategy + assert event.current_debt == 0 + assert event.new_debt == assets - min_idle + + assert vault.totalAssets() == assets + assert vault.totalIdle() == min_idle + assert vault.totalDebt() == assets - min_idle + assert strategy.totalAssets() == assets - min_idle + assert strategy.balanceOf(vault) == assets - min_idle + assert vault.strategies(strategy)["current_debt"] == assets - min_idle + assert vault.balanceOf(fish) == assets + + +def test_deposit__auto_update_debt__min_idle_not_met( + asset, fish, fish_amount, gov, vault, strategy, user_deposit +): + assets = fish_amount + min_idle = assets * 2 + + assert vault.auto_allocate() == False + + vault.set_auto_allocate(True, sender=gov) + vault.update_max_debt_for_strategy(strategy, 2**256 - 1, sender=gov) + vault.set_minimum_total_idle(min_idle, sender=gov) + + assert vault.auto_allocate() + assert strategy.maxDeposit(vault) > assets + assert vault.strategies(strategy)["max_debt"] > assets + assert vault.minimum_total_idle() == min_idle + + assert vault.totalAssets() == 0 + assert vault.totalIdle() == 0 + assert vault.totalDebt() == 0 + assert strategy.totalAssets() == 0 + assert strategy.balanceOf(vault) == 0 + assert vault.strategies(strategy)["current_debt"] == 0 + assert vault.balanceOf(fish) == 0 + + asset.approve(vault, assets, sender=fish) + + tx = vault.deposit(assets, fish, sender=fish) + + event = tx.decode_logs(vault.DebtUpdated) + + assert len(event) == 0 + + assert vault.totalAssets() == assets + assert vault.totalIdle() == assets + assert vault.totalDebt() == 0 + assert strategy.totalAssets() == 0 + assert strategy.balanceOf(vault) == 0 + assert vault.strategies(strategy)["current_debt"] == 0 + assert vault.balanceOf(fish) == assets + + +def test_deposit__auto_update_debt__current_debt_more_than_max_debt( + asset, fish, fish_amount, gov, vault, strategy, user_deposit +): + assets = fish_amount // 2 + max_debt = assets + + assert vault.auto_allocate() == False + + vault.set_auto_allocate(True, sender=gov) + vault.update_max_debt_for_strategy(strategy, max_debt, sender=gov) + + assert vault.auto_allocate() + assert strategy.maxDeposit(vault) > assets + assert vault.strategies(strategy)["max_debt"] == assets + assert vault.minimum_total_idle() == 0 + + assert vault.totalAssets() == 0 + assert vault.totalIdle() == 0 + assert vault.totalDebt() == 0 + assert strategy.totalAssets() == 0 + assert strategy.balanceOf(vault) == 0 + assert vault.strategies(strategy)["current_debt"] == 0 + assert vault.balanceOf(fish) == 0 + + asset.approve(vault, assets, sender=fish) + + tx = vault.deposit(assets, fish, sender=fish) + + event = tx.decode_logs(vault.DebtUpdated) + + assert len(event) == 1 + event = event[0] + + assert event.strategy == strategy + assert event.current_debt == 0 + assert event.new_debt == max_debt + + assert vault.totalAssets() == assets + assert vault.totalIdle() == 0 + assert vault.totalDebt() == max_debt + assert strategy.totalAssets() == max_debt + assert strategy.balanceOf(vault) == max_debt + assert vault.strategies(strategy)["current_debt"] == max_debt + assert vault.balanceOf(fish) == assets + + profit = assets // 10 + # Report profit to go over max debt + asset.mint(strategy, profit, sender=gov) + strategy.report(sender=gov) + vault.process_report(strategy, sender=gov) + + assert ( + vault.strategies(strategy)["current_debt"] + > vault.strategies(strategy)["max_debt"] + ) + + asset.approve(vault, assets, sender=fish) + + tx = vault.deposit(assets, fish, sender=fish) + + event = tx.decode_logs(vault.DebtUpdated) + + assert len(event) == 0 + + assert vault.totalAssets() == assets * 2 + profit + assert vault.totalIdle() == assets + assert vault.totalDebt() == max_debt + profit + assert strategy.totalAssets() == max_debt + profit + assert strategy.balanceOf(vault) == max_debt + assert vault.strategies(strategy)["current_debt"] == max_debt + profit + assert vault.balanceOf(fish) > assets diff --git a/tests/unit/vault/test_debt_management.py b/tests/unit/vault/test_debt_management.py index 37eccf0f..4679bfc5 100644 --- a/tests/unit/vault/test_debt_management.py +++ b/tests/unit/vault/test_debt_management.py @@ -33,7 +33,7 @@ def test_update_debt__without_permission__reverts(gov, vault, asset, strategy, b vault.update_debt(strategy.address, new_debt, sender=bunny) -def test_update_debt__with_strategy_max_debt_less_than_new_debt__reverts( +def test_update_debt__with_strategy_max_debt_less_than_new_debt( gov, asset, vault, strategy ): vault_balance = asset.balanceOf(vault) @@ -41,8 +41,22 @@ def test_update_debt__with_strategy_max_debt_less_than_new_debt__reverts( vault.update_max_debt_for_strategy(strategy.address, new_debt, sender=gov) - with ape.reverts("target debt higher than max debt"): - vault.update_debt(strategy.address, new_debt + 1, sender=gov) + tx = vault.update_debt(strategy.address, new_debt + 10, sender=gov) + + assert tx.return_value == new_debt + + event = list(tx.decode_logs(vault.DebtUpdated)) + + assert len(event) == 1 + assert event[0].strategy == strategy.address + assert event[0].current_debt == 0 + assert event[0].new_debt == new_debt + + assert vault.strategies(strategy.address).current_debt == new_debt + assert asset.balanceOf(strategy) == new_debt + assert asset.balanceOf(vault) == vault_balance - new_debt + assert vault.totalIdle() == vault_balance - new_debt + assert vault.totalDebt() == new_debt def test_update_debt__with_current_debt_less_than_new_debt(gov, asset, vault, strategy): @@ -82,7 +96,7 @@ def test_update_debt__with_current_debt_equal_to_new_debt__reverts( vault.update_debt(strategy.address, new_debt, sender=gov) -def test_update_debt__with_current_debt_greater_than_new_debt_and_zero_withdrawable__reverts( +def test_update_debt__with_current_debt_greater_than_new_debt_and_zero_withdrawable( gov, asset, vault, locked_strategy, add_debt_to_strategy ): vault_balance = asset.balanceOf(vault) @@ -95,8 +109,9 @@ def test_update_debt__with_current_debt_greater_than_new_debt_and_zero_withdrawa # reduce debt in strategy vault.update_max_debt_for_strategy(locked_strategy.address, new_debt, sender=gov) - with ape.reverts("nothing to withdraw"): - vault.update_debt(locked_strategy.address, new_debt, sender=gov) + tx = vault.update_debt(locked_strategy.address, new_debt, sender=gov) + + assert tx.return_value == current_debt def test_update_debt__with_current_debt_greater_than_new_debt_and_strategy_has_losses__reverts( @@ -269,7 +284,7 @@ def test_update_debt__with_current_debt_less_than_new_debt_and_minimum_total_idl assert vault.totalIdle() > vault.minimum_total_idle() -def test_update_debt__with_current_debt_less_than_new_debt_and_total_idle_lower_than_minimum_total_idle__revert( +def test_update_debt__with_current_debt_less_than_new_debt_and_total_idle_lower_than_minimum_total_idle( gov, asset, vault, strategy ): """ @@ -287,8 +302,9 @@ def test_update_debt__with_current_debt_less_than_new_debt_and_total_idle_lower_ # increase debt in strategy vault.update_max_debt_for_strategy(strategy.address, new_debt, sender=gov) - with ape.reverts("no funds to deposit"): - vault.update_debt(strategy.address, new_debt, sender=gov) + tx = vault.update_debt(strategy.address, new_debt, sender=gov) + + assert tx.return_value == 0 def test_update_debt__with_current_debt_less_than_new_debt_and_minimum_total_idle_reducing_new_debt( diff --git a/tests/unit/vault/test_emergency_shutdown.py b/tests/unit/vault/test_emergency_shutdown.py index 2296438e..593cfe58 100644 --- a/tests/unit/vault/test_emergency_shutdown.py +++ b/tests/unit/vault/test_emergency_shutdown.py @@ -26,7 +26,7 @@ def test_shutdown_gives_debt_manager_role(gov, panda, vault): vault.set_role(panda.address, ROLES.EMERGENCY_MANAGER, sender=gov) assert ROLES.DEBT_MANAGER not in ROLES(vault.roles(panda)) vault.shutdown_vault(sender=panda) - assert ROLES.DEBT_MANAGER in ROLES(vault.roles(panda)) + assert ROLES.DEBT_MANAGER | ROLES.EMERGENCY_MANAGER == vault.roles(panda) def test_shutdown__increase_deposit_limit__reverts( diff --git a/tests/unit/vault/test_erc4626.py b/tests/unit/vault/test_erc4626.py index 4d4f6d0f..9504fedd 100644 --- a/tests/unit/vault/test_erc4626.py +++ b/tests/unit/vault/test_erc4626.py @@ -785,6 +785,29 @@ def test_max_redeem__with_withdraw_limit_module( assert vault.maxRedeem(bunny.address) == 0 +def test_deposit__with_max_uint( + asset, fish, fish_amount, gov, create_vault, deploy_limit_module, user_deposit +): + vault = create_vault(asset) + assets = fish_amount + + assert asset.balanceOf(fish) == fish_amount + + asset.approve(vault.address, assets, sender=fish) + + # Should go through now + tx = vault.deposit(MAX_INT, fish.address, sender=fish) + + event = list(tx.decode_logs(vault.Deposit))[0] + + assert event.assets == assets + assert event.shares == assets + assert event.owner == fish + assert event.sender == fish + assert vault.balanceOf(fish.address) == assets + assert asset.balanceOf(vault.address) == assets + + def test_deposit__with_deposit_limit_module( asset, fish, fish_amount, gov, create_vault, deploy_limit_module, user_deposit ): diff --git a/tests/unit/vault/test_protocol_fees.py b/tests/unit/vault/test_protocol_fees.py index 9dc85135..83dd8dee 100644 --- a/tests/unit/vault/test_protocol_fees.py +++ b/tests/unit/vault/test_protocol_fees.py @@ -1,6 +1,6 @@ from ape import chain import pytest -from utils.constants import ROLES, YEAR, MAX_BPS_ACCOUNTANT +from utils.constants import ROLES, YEAR, MAX_BPS_ACCOUNTANT, ZERO_ADDRESS from utils.utils import days_to_secs @@ -18,7 +18,7 @@ def test__report_with_no_protocol_fees__no_accountant_fees( ): amount = fish_amount // 10 - assert vault_factory.protocol_fee_config().fee_bps == 0 + assert vault_factory.protocol_fee_config() == (0, ZERO_ADDRESS) vault = create_vault(asset) strategy = create_strategy(vault) diff --git a/tests/unit/vault/test_role_base_access.py b/tests/unit/vault/test_role_base_access.py index e04da9a3..ac9000f7 100644 --- a/tests/unit/vault/test_role_base_access.py +++ b/tests/unit/vault/test_role_base_access.py @@ -684,3 +684,43 @@ def test__remove_role__wont_add(gov, vault, bunny, strategy): with ape.reverts("not allowed"): vault.add_strategy(strategy, sender=bunny) + + +def test__set_name(gov, vault, bunny): + name = vault.name() + new_name = "New Vault Name" + + with ape.reverts("not allowed"): + vault.setName(new_name, sender=bunny) + + vault.set_role(bunny, ROLES.ALL, sender=gov) + + with ape.reverts("not allowed"): + vault.setName(new_name, sender=bunny) + + assert vault.name() != new_name + + vault.setName(new_name, sender=gov) + + assert vault.name() == new_name + assert vault.name() != name + + +def test__set_symbol(gov, vault, bunny): + symbol = vault.name() + new_symbol = "New Vault symbol" + + with ape.reverts("not allowed"): + vault.setSymbol(new_symbol, sender=bunny) + + vault.set_role(bunny, ROLES.ALL, sender=gov) + + with ape.reverts("not allowed"): + vault.setSymbol(new_symbol, sender=bunny) + + assert vault.symbol() != new_symbol + + vault.setSymbol(new_symbol, sender=gov) + + assert vault.symbol() == new_symbol + assert vault.symbol() != symbol diff --git a/tests/unit/vault/test_shares.py b/tests/unit/vault/test_shares.py index 9ddca2f6..ea0a31fe 100644 --- a/tests/unit/vault/test_shares.py +++ b/tests/unit/vault/test_shares.py @@ -18,7 +18,7 @@ def test_deposit__with_zero_funds__reverts(fish, asset, create_vault): vault = create_vault(asset) amount = 0 - with ape.reverts("cannot mint zero"): + with ape.reverts("cannot deposit zero"): vault.deposit(amount, fish.address, sender=fish) @@ -471,7 +471,7 @@ def create_profit( return event[0].total_fees -def test__mint_shares_with_zero_total_supply_positive_assets( +def test__deposit_shares_with_zero_total_supply_positive_assets( asset, fish_amount, fish, initial_set_up, gov ): amount = fish_amount // 10 @@ -498,3 +498,90 @@ def test__mint_shares_with_zero_total_supply_positive_assets( # shares should be minted at 1:1 assert vault.balanceOf(fish) == amount assert vault.pricePerShare() > (10 ** vault.decimals()) + + +def test__mint_shares_with_zero_total_supply_positive_assets( + asset, fish_amount, fish, initial_set_up, gov +): + amount = fish_amount // 10 + first_profit = fish_amount // 10 + + vault, strategy, _ = initial_set_up(asset, gov, amount, fish) + create_profit(asset, strategy, gov, vault, first_profit) + vault.update_debt(strategy, int(0), sender=gov) + assert ( + vault.totalSupply() > amount + ) # there are more shares than deposits (due to profit unlock) + + # User redeems shares + vault.redeem(vault.balanceOf(fish), fish, fish, sender=fish) + + assert vault.totalSupply() > 0 + + ape.chain.mine(timestamp=ape.chain.pending_timestamp + 14 * 24 * 3600) + + assert vault.totalSupply() == 0 + + vault.mint(amount, fish, sender=fish) + + # shares should be minted at 1:1 + assert vault.balanceOf(fish) == amount + assert vault.pricePerShare() > (10 ** vault.decimals()) + + +def test__deposit_with_zero_total_assets_positive_supply( + asset, fish_amount, fish, initial_set_up, gov +): + amount = fish_amount // 10 + + vault, strategy, _ = initial_set_up(asset, gov, amount, fish) + + # Create a loss + asset.transfer(gov, amount, sender=strategy) + strategy.report(sender=gov) + + assert strategy.convertToAssets(amount) == 0 + + vault.process_report(strategy, sender=gov) + + assert vault.totalAssets() == 0 + assert vault.totalSupply() != 0 + + with ape.reverts("cannot mint zero"): + vault.deposit(amount, fish, sender=fish) + + # shares should not be minted + assert vault.balanceOf(fish) == amount + assert vault.pricePerShare() == 0 + assert vault.convertToShares(amount) == 0 + assert vault.convertToAssets(amount) == 0 + # assert vault.maxDeposit(fish) == 0 + + +def test__mint_with_zero_total_assets_positive_supply( + asset, fish_amount, fish, initial_set_up, gov +): + amount = fish_amount // 10 + + vault, strategy, _ = initial_set_up(asset, gov, amount, fish) + + # Create a loss + asset.transfer(gov, amount, sender=strategy) + strategy.report(sender=gov) + + assert strategy.convertToAssets(amount) == 0 + + vault.process_report(strategy, sender=gov) + + assert vault.totalAssets() == 0 + assert vault.totalSupply() != 0 + + with ape.reverts("cannot deposit zero"): + vault.mint(amount, fish, sender=fish) + + # shares should not be minted + assert vault.balanceOf(fish) == amount + assert vault.pricePerShare() == 0 + assert vault.convertToShares(amount) == 0 + assert vault.convertToAssets(amount) == 0 + # assert vault.maxMint(fish) == 0 diff --git a/tests/unit/vault/test_vault_accounting.py b/tests/unit/vault/test_vault_accounting.py index 0a9b9a46..85708813 100644 --- a/tests/unit/vault/test_vault_accounting.py +++ b/tests/unit/vault/test_vault_accounting.py @@ -14,3 +14,39 @@ def test_vault_airdrop_do_not_increase( price_per_share = vault.pricePerShare() airdrop_asset(gov, asset, vault, int(vault_balance / 10)) assert vault.pricePerShare() == price_per_share + + +def test_vault_airdrop_do_not_increase_report_records_it( + gov, asset, vault, mint_and_deposit_into_vault, airdrop_asset +): + mint_and_deposit_into_vault(vault, gov) + vault_balance = asset.balanceOf(vault) + assert vault_balance != 0 + # vault. + # aidrop to vault + price_per_share = vault.pricePerShare() + + to_airdrop = int(vault_balance / 10) + airdrop_asset(gov, asset, vault, to_airdrop) + + assert vault.pricePerShare() == price_per_share + assert vault.totalIdle() == vault_balance + assert asset.balanceOf(vault) == vault_balance + to_airdrop + + tx = vault.process_report(vault.address, sender=gov) + + event = list(tx.decode_logs(vault.StrategyReported))[0] + + assert event.strategy == vault.address + assert event.gain == to_airdrop + assert event.loss == 0 + assert event.current_debt == vault_balance + to_airdrop + assert event.total_fees == 0 + + # Profit is locked + assert vault.pricePerShare() == price_per_share + assert vault.totalIdle() == vault_balance + to_airdrop + assert asset.balanceOf(vault) == vault_balance + to_airdrop + + chain.pending_timestamp = chain.pending_timestamp + vault.profitMaxUnlockTime() - 1 + chain.mine(timestamp=chain.pending_timestamp)