diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..a84ffb50 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,90 @@ +# Contribution guide + +If you are new to [Yearn Finance](https://yearn.finance/), you might want to familiarize yourself with its [core concepts and products](https://docs.yearn.finance/). You can also join the [discord channel](https://discord.com/invite/6PNv2nF/) if you have questions or to keep up with updates. + +## Setting up your environment + +Before proceeding, please set up your environment by following these installation, building and testing [instructions](https://github.com/yearn/yearn-vaults-v3/blob/master/README.md). + +## Making your first contribution + +Each time you begin a set of changes, ensure that you are working on a new branch that you have created as opposed to the `master` of your local repository. By keeping your changes segregated in this branch, merging your changes into the main repository later will be much simpler for the team. + +To create a local branch for `git` to checkout, issue the command: + +```bash +git checkout -b feature-in-progress-branch +``` + +To checkout a branch you have already created: + +```bash +git checkout feature-in-progress-branch +``` + +### Preparing your commit + +The official yearn-vaults-v3 repository may have changed since the time you cloned it. To fetch changes to the yearn-vaults-v3 repository since your last session: + +```bash +git fetch origin +``` + +Then synchronize your master branch: + +```bash +git pull origin master + +``` + +To stage the changed files that are be committed, issue the command: + +```bash +git add -a +``` + +Once you are ready to make a commit, you can do so with: + +```bash +git commit -m “fix: message to explain what the commit covers” +``` + +**NOTE**: commit message must follow Conventional Commits [standard](https://www.conventionalcommits.org/en/v1.0.0/), otherwise your pull requests (discussed further below below) will not pass validation tests. You can use the [`--amend` flag](https://git-scm.com/docs/git-commit) to effectively change your commit message. + +### Handling conflicts + +If there are conflicts between your edits and those made by others since you started work Git will ask you to resolve them. To find out which files have conflicts, run: + +```bash +git status +``` + +Open those files, and you will see lines inserted by Git that identify the conflicts: + +```text +<<<<<< HEAD +Other developers’ version of the conflicting code +====== +Your version of the conflicting code +'>>>>> Your Commit +``` + +The code from the yearn-vaults-v3 repository is inserted between `<<<` and `===` while the change you have made is inserted between `===` and `>>>>`. Remove everything between `<<<<` and `>>>` and replace it with code that resolves the conflict. Repeat the process for all files listed by Git status to have conflicts. + +When you are ready, use git push to move your local copy of the changes to your fork of the repository on Github. + +```bash +git push git@github.com:/yearn-vaults-v3.git feature-in-progress-branch +``` + +### Opening a pull request + +Navigate to your fork of the repository on Github. In the upper left where the current branch is listed, change the branch to your newly created one (feature-in-progress-branch). Open the files that you have worked on and ensure they include your changes. + +Navigate to yearn-vaults-v3 [repository](https://github.com/yearn/yearn-vaults-v3/tree/master) and click on the new pull request button. In the “base” box on the left, leave the default selection “base master”, the branch that you want your changes to be applied to. In the “compare” box on the right, select the branch containing the changes you want to apply. You will then be asked to answer a few questions about your pull request. Pull requests should have enough context about what you are working on, how you are solving a problem, and reference all necessary information for your reviewers to help. + +After you complete the questionnaire, the pull request will appear in the [list](https://github.com/yearn/yearn-vaults-v3/pulls) of pull requests. + +### Following up + +Core developers may ask questions and request that you make edits. If you set notifications at the top of the page to “not watching,” you will still be notified by email whenever someone comments on the page of a pull request you have created. If you are asked to modify your pull request, edit your local branch, push up your fixes, then leave a comment to notify the original reviewer that the pull request is ready for further review. \ No newline at end of file diff --git a/README.md b/README.md index a9d731a4..b072e72e 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ and test smart contracts with: ape test ``` +### To make a contribution please follow the [guidelines](https://github.com/yearn/yearn-vaults-v3/bloc/master/CONTRIBUTING.md) + See the ApeWorx [documentation](https://docs.apeworx.io/ape/stable/) and [github](https://github.com/ApeWorX/ape) for more information. You will need hardhat to run the test `yarn` diff --git a/TECH_SPEC.md b/TECH_SPEC.md index 7c226c0d..cb4254be 100644 --- a/TECH_SPEC.md +++ b/TECH_SPEC.md @@ -11,15 +11,15 @@ - Queue_Manager: smart contract that can be configured by management to hold the optimal withdrawal queues for each vault # VaultV3 Specification -The Vault code has been designed as an unopinionated system to distribute funds of depositors into different opportunities (aka Strategies) and manage accounting in a robust way. That's all. +The Vault code has been designed as an non-opinionated system to distribute funds of depositors into different opportunities (aka Strategies) and manage accounting in a robust way. That's all. -The depositors receive shares of the different investments that can then be redeemed or used as yield-bearing tokens. +Depositors receive shares (aka vaults tokens) proportional to their deposit amount. Vault tokens are yield-bearing and can be redeemed at any time to get back deposit plus any yield generated. The Vault does not have a preference on any of the dimensions that should be considered when operating a vault: -- *Decentralization*: roles can be filled by EOA, smart contract like multisig or governance module -- *Liquidity*: vault can have 0 liquidity or be fully liquid. It will depend on parameters and strategies added -- *Security*: vault managers can choose what strategies to add and how to do that process -- *Automation*: all the required actions to maintain the vault can be called by bots or manually, depending on periphery implementation +- *Decentralization*: Roles can be filled by any address (e.g. EOA, smart contract, multi-sig). +- *Liquidity*: Vault can have 0 liquidity or be fully liquid. It will depend on parameters and strategies added. +- *Security*: Vault managers can choose what strategies to add and how to do that process. +- *Automation*: All the required actions to maintain the vault can be called by bots or manually, depending on periphery implementation. The compromises will come with the implementation of periphery contracts fulfilling the roles in the Vault. @@ -56,7 +56,7 @@ Users can redeem their shares at any point in time if there is liquidity availab Optionally, a user can specify a list of strategies to withdraw from. If a list of strategies is passed, the vault will try to withdraw from them. -If a user passed array is not defined. The redeem function will check if there is a queue_manager set to get a valid withdraw queue from. If neither happens the vault will check if there are enough idle funds to serve the request. If there are not enough, it will revert. +If a user passed array is not defined, the redeem function will use the default_queue. If not enough funds have been recovered to honor the full request, the transaction will revert. @@ -92,9 +92,9 @@ new_profit_distribution_rate = (locked_profit + new_profit) / new_locking_period Losses will be offset by locked profit, if possible. -Issue of new shares due to fees will also unlock profit so that pps does not go down. +Issue of new shares due to fees will also unlock profit so that PPS does not go down. -Both of this offsets will prevent frontrunning (as the profit was already earned and was not distributed yet) +Both of this offsets will prevent front running (as the profit was already earned and was not distributed yet) ## Vault Management Vault management is split into function specific roles. Each permissioned function has its own corresponding Role. @@ -109,7 +109,7 @@ These are: - REVOKE_STRATEGY_MANAGER: role that can remove strategies from the vault - FORCE_REVOKE_MANAGER: role that can force remove a strategy causing a loss - ACCOUNTANT_MANAGER: role that can set the accountant that assesses fees -- QUEUE_MANAGER: role that can set the queue_manager +- QUEUE_MANAGER: role that can set the default_queue - REPORTING_MANAGER: role that calls report for strategies - DEBT_MANAGER: role that adds and removes debt from strategies - MAX_DEBT_MANAGER: role that can set the max debt for a strategy @@ -119,16 +119,16 @@ These are: - DEBT_PURCHASER # can purchase bad debt from the vault - EMERGENCY_MANAGER: role that can shutdown vault in an emergency -Every role can be filled by an EOA, multisig or other smart contracts. Each role can be filled by several accounts. +Every role can be filled by an EOA, multi-sig or other smart contracts. Each role can be filled by several accounts. The account that manages roles is a single account, set in `role_manager`. -This role_manager can be an EOA, a multisig or a Governance Module that relays calls. +This role_manager can be an EOA, a multi-sig or a Governance Module that relays calls. ### Strategy Management This responsibility is taken by callers with ADD_STRATEGY_MANAGER, REVOKE_STRATEGY_MANAGER and FORCE_REVOKE_MANAGER roles -A vault can have strategies added, removed oe forcefully removed +A vault can have strategies added, removed or forcefully removed Added strategies will be eligible to receive funds from the vault, when the max_debt is set to > 0 @@ -137,9 +137,9 @@ Revoked strategies will return all debt and stop being eligible to receive more. Force revoking a strategy is only used in cases of a faulty strategy that cannot otherwise have its current_debt reduced to 0. Force revoking a strategy will result in a loss being reported by the vault. #### Setting the periphery contracts -The accountant and the queue_manager contracts can each be set by the ACCOUNTANT_MANAGER and QUEUE_MANAGER respectfully +The accountant can each be set by the ACCOUNTANT_MANAGER. -The contracts are not needed for the vault to function but are recommended for optimal use +The contract is not needed for the vault to function but are recommended for optimal use. #### Reporting profits The REPORTING_MANAGER is in charge of calling process_report() for each strategy in the vault according to its own timeline @@ -164,7 +164,7 @@ The MAX_DEBT_MANAGER can set the maximum amount of tokens the vault will allow a Stored in strategies[strategy].max_debt -When a debt rebalance is triggered, the Vault will cap the new target debt to this number (max_debt) +When a debt re-balance is triggered, the Vault will cap the new target debt to this number (max_debt) #### Setting the deposit limit The DEPOSIT_LIMIT_MANAGER is in charge of setting the deposit_limit for the vault @@ -183,6 +183,11 @@ The PROFIT_UNLOCK_MANAGER is in charge of updating and setting the profit_max_un This can be customized based on the vault based on aspects such as number of strategies, TVL, expected returns etc. +### Setting the default queue +The QUEUE_MANAGER has the option to set a custom default_queue if desired. The vault will arrange the default queue automatically based only on the order that strategies were added to the vault. If a different order is desired the queue manager role can set a custom queue. + +All strategies in the default queue must have been previously added to the vault. + #### Buying Debt The DEBT_PURCHASER role can buy debt from the vault in return for the equal amount of `asset`. @@ -237,10 +242,10 @@ Shutdown mode does not affect accounting ### Debt rebalance _Light emergency_: Setting minimumTotalIdle to MAX_UINT256 will result in the vault requesting the debt back from strategies. This would stop new strategies from getting funded too, as the vault prioritizes minimumTotalIdle -_Shutdown mode_: All strategies' maxDebt is set to 0. Strategies will return funds as soon as they can. +_Shutdown mode_: All strategies maxDebt is set to 0. Strategies will return funds as soon as they can. ### Relevant emergency -In the case the current roles stop fulfilling their responsibilities or something else's happens, the EMERGENCY_MANAGER can shutdown the vault. +In the case the current roles stop fulfilling their responsibilities or something else happens, the EMERGENCY_MANAGER can shutdown the vault. The shutdown mode should be the last option in an emergency as it is irreversible. diff --git a/contracts/VaultFactory.vy b/contracts/VaultFactory.vy index d3d9a922..83eaec78 100644 --- a/contracts/VaultFactory.vy +++ b/contracts/VaultFactory.vy @@ -4,6 +4,32 @@ @title Yearn Vault Factory @license GNU AGPLv3 @author yearn.finance +@notice + This vault Factory can be used by anyone wishing to deploy their own + ERC4626 compliant Vault. + + The factory uses the Blueprint (ERC-5202) standard to handle the + deployment of any new vaults off of the immutable address stored + at `VAULT_BLUEPRINT`. This allows the vaults to be deployed and + initialized fully on-chain with their init byte code, thus not + requiring any delegatecall patterns or post deployment initialization. + The deployments are done through create2 with a specific `salt` + that is dereived from a combination of the deployers address, + the underlying asset used, as well as the name and symbol specified. + Meaning a deployer will not be able to deploy the exact same vault + twice and will need to use different name and or symbols for vaults + that use the same other parameters such as `asset`. + + The factory also holds the protocol fee configs for each vault and strategy + of its specific `API_VERSION` that determine how much of the fees + charged are designated "protocol fees" and sent to the designated + `fee_recipient`. The protocol fees work through rev share system, + where if the vault or strategy determines to charge X amount of total + fees during a `report` the protocol fees are X * fee_bps / 10_000. + The protocol fees will be sent to the designated fee_recipient and + then (X - protocol_fees) will be sent to the vault/strategy specific + fee recipient. + """ from vyper.interfaces import ERC20 @@ -39,14 +65,15 @@ event NewPendingGovernance: struct PFConfig: # Percent of protocol's split of fees in Basis Points. fee_bps: uint16 - # Address for protocol fees to get paid to. + # Address the protocol fees get paid to. fee_recipient: address -# The max amount the protocol fee can be set to. -MAX_FEE_BPS: constant(uint16) = 5_000 # Identifier for this version of the vault. API_VERSION: constant(String[28]) = "3.0.1-beta" +# The max amount the protocol fee can be set to. +MAX_FEE_BPS: constant(uint16) = 5_000 # 50% + # The address that all newly deployed vaults are based from. VAULT_BLUEPRINT: immutable(address) @@ -83,16 +110,18 @@ def deploy_new_vault( profit_max_unlock_time: uint256 ) -> address: """ - @notice Deploys a new vault - @param asset The asset to be used for the vault - @param name The name of the vault - @param symbol The symbol of the vault - @param role_manager The address of the role manager - @param profit_max_unlock_time The maximum time that the profit can be locked for - @return The address of the new vault + @notice Deploys a new vault base on the bLueprint. + @param asset The asset to be used for the vault. + @param name The name of the new vault. + @param symbol The symbol of the new vault. + @param role_manager The address of the role manager. + @param profit_max_unlock_time The time over which the profits will unlock. + @return The address of the new vault. """ + # Make sure the factory is not shutdown. assert not self.shutdown, "shutdown" + # Deploy the new vault using the blueprint. vault_address: address = create_from_blueprint( VAULT_BLUEPRINT, asset, @@ -132,7 +161,7 @@ def protocol_fee_config() -> PFConfig: @notice Called during vault and strategy reports to retreive the protocol fee to charge and address to receive the fees. - @return The protocol fee config for the msg sender + @return The protocol fee config for the msg sender. """ # If there is a custom protocol fee set we return it. if self.use_custom_protocol_fee[msg.sender]: @@ -168,6 +197,7 @@ def set_protocol_fee_bps(new_protocol_fee_bps: uint16): def set_protocol_fee_recipient(new_protocol_fee_recipient: address): """ @notice Set the protocol fee recipient + @dev Can never be set to 0 to avoid issuing fees to the 0 addresss. @param new_protocol_fee_recipient The new protocol fee recipient """ assert msg.sender == self.governance, "not governance" diff --git a/contracts/VaultV3.vy b/contracts/VaultV3.vy index 2c239968..eb6ac4d6 100644 --- a/contracts/VaultV3.vy +++ b/contracts/VaultV3.vy @@ -4,6 +4,32 @@ @title Yearn V3 Vault @license GNU AGPLv3 @author yearn.finance +@notice + The Yearn VaultV3 is designed as an non-opinionated system to distribute funds of + depositors for a specific `asset` into different opportunities (aka Strategies) + and manage accounting in a robust way. + + Depositors receive shares (aka vaults tokens) proportional to their deposit amount. + Vault tokens are yield-bearing and can be redeemed at any time to get back deposit + plus any yield generated. + + Addresses that are given different permissioned roles by the `role_manager` + are then able to allocate funds as they best see fit to different strategies + and adjust the strategies and allocations as needed, as well as reporting realized + profits or losses. + + Strategies are any ERC-4626 compliant contracts that use the same underlying `asset` + as the vault. The vault provides no assurances as to the safety of any strategy + and it is the responsibility of those that hold the corresponding roles to choose + and fund strategies that best fit their desired specifications. + + Those holding vault tokens are able to redeem the tokens for the corresponding + amount of underlying asset based on any reported profits or losses since their + initial deposit. + + The vault is built to be customized by the management to be able to fit their + specific desired needs Including the customization of strategies, accountants, + ownership etc. """ from vyper.interfaces import ERC20 @@ -106,24 +132,32 @@ event UpdateMinimumTotalIdle: event UpdateProfitMaxUnlockTime: profit_max_unlock_time: uint256 -event Shutdown: - pass - event DebtPurchased: strategy: indexed(address) amount: uint256 +event Shutdown: + pass + # STRUCTS # struct StrategyParams: - activation: uint256 + # Timestamp when the strategy was added. + activation: uint256 + # Timestamp of the strategies last report. last_report: uint256 + # The current assets the strategy holds. current_debt: uint256 + # The max assets the strategy can hold. max_debt: uint256 # CONSTANTS # +# The max length the withdrawal queue can be. MAX_QUEUE: constant(uint256) = 10 +# 100% in Basis Points. 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.1-beta" # ENUMS # @@ -131,19 +165,19 @@ API_VERSION: constant(String[28]) = "3.0.1-beta" # Roles can be combined in any combination or all kept seperate. # Follows python Enum patterns so the first Enum == 1 and doubles each time. enum Roles: - ADD_STRATEGY_MANAGER # can add strategies to the vault - REVOKE_STRATEGY_MANAGER # can remove strategies from the vault - FORCE_REVOKE_MANAGER # can force remove a strategy causing a loss - ACCOUNTANT_MANAGER # can set the accountant that assesss fees - QUEUE_MANAGER # can set the default withdrawal queue. - REPORTING_MANAGER # calls report for strategies - DEBT_MANAGER # adds and removes debt from strategies - MAX_DEBT_MANAGER # can set the max debt for a strategy - DEPOSIT_LIMIT_MANAGER # sets deposit limit for the vault - MINIMUM_IDLE_MANAGER # sets the minimun total idle the vault should keep - PROFIT_UNLOCK_MANAGER # sets the profit_max_unlock_time - DEBT_PURCHASER # can purchase bad debt from the vault - EMERGENCY_MANAGER # can shutdown vault in an emergency + ADD_STRATEGY_MANAGER # Can add strategies to the vault. + REVOKE_STRATEGY_MANAGER # Can remove strategies from the vault. + FORCE_REVOKE_MANAGER # Can force remove a strategy causing a loss. + ACCOUNTANT_MANAGER # Can set the accountant that assesss fees. + QUEUE_MANAGER # Can set the default withdrawal queue. + REPORTING_MANAGER # Calls report for strategies. + DEBT_MANAGER # Adds and removes debt from strategies. + MAX_DEBT_MANAGER # Can set the max debt for a strategy. + DEPOSIT_LIMIT_MANAGER # Sets deposit limit for the vault. + MINIMUM_IDLE_MANAGER # Sets the minimun total idle the vault should keep. + PROFIT_UNLOCK_MANAGER # Sets the profit_max_unlock_time. + DEBT_PURCHASER # Can purchase bad debt from the vault. + EMERGENCY_MANAGER # Can shutdown vault in an emergency. enum StrategyChangeType: ADDED @@ -158,8 +192,11 @@ enum RoleStatusChange: CLOSED # IMMUTABLE # +# Underlying token used by the vault. ASSET: immutable(ERC20) +# Based off the `asset` decimals. DECIMALS: immutable(uint256) +# Deployer contract used to retreive the protocol fee config. FACTORY: public(immutable(address)) # STORAGE # @@ -172,34 +209,34 @@ default_queue: public(DynArray[address, MAX_QUEUE]) balance_of: HashMap[address, uint256] # ERC20 - owner -> (spender -> amount) allowance: public(HashMap[address, HashMap[address, uint256]]) -# Total amount of shares that are currently minted -# To get the ERC20 compliant version user totalSupply(). +# Total amount of shares that are currently minted including those locked. +# NOTE: To get the ERC20 compliant version user totalSupply(). total_supply: public(uint256) -# Total amount of assets that has been deposited in strategies +# 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 +# Current assets held in the vault contract. Replacing balanceOf(this) to avoid price_per_share manipulation. total_idle: uint256 -# Minimum amount of assets that should be kept in the vault contract to allow for fast, cheap redeems +# 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 +# Maximum amount of tokens that the vault can accept. If totalAssets > deposit_limit, deposits will revert. deposit_limit: public(uint256) -# Contract that charges fees and can give refunds +# Contract that charges fees and can give refunds. accountant: public(address) # HashMap mapping addresses to their roles roles: public(HashMap[address, Roles]) -# HashMap mapping roles to their permissioned state. If false, the role is not open to the public +# HashMap mapping roles to their permissioned state. If false, the role is not open to the public. open_roles: public(HashMap[Roles, bool]) -# Address that can add and remove addresses to roles +# Address that can add and remove roles to addresses. role_manager: public(address) -# Temporary variable to store the address of the next role_manager until the role is accepted +# 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 +# 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 +# ERC20 - name of the vaults token. name: public(String[64]) -# ERC20 - symbol of the token +# ERC20 - symbol of the vaults token. symbol: public(String[32]) # The amount of time profits will unlock over. @@ -218,7 +255,13 @@ PERMIT_TYPE_HASH: constant(bytes32) = keccak256("Permit(address owner,address sp # Constructor @external -def __init__(asset: ERC20, name: String[64], symbol: String[32], role_manager: address, profit_max_unlock_time: uint256): +def __init__( + asset: ERC20, + name: String[64], + symbol: String[32], + role_manager: address, + profit_max_unlock_time: uint256 +): """ @notice The constructor for the vault. Sets the asset, name, symbol, and role manager. @@ -231,7 +274,7 @@ def __init__(asset: ERC20, name: String[64], symbol: String[32], role_manager: a @param role_manager The address that can add and remove roles to addresses @param profit_max_unlock_time - The maximum amount of time that the profit can be locked for + The amount of time that the profit will be locked for """ ASSET = asset DECIMALS = convert(ERC20Detailed(asset.address).decimals(), uint256) @@ -292,7 +335,15 @@ def _decrease_allowance(owner: address, spender: address, amount: uint256) -> bo return True @internal -def _permit(owner: address, spender: address, amount: uint256, deadline: uint256, v: uint8, r: bytes32, s: bytes32) -> bool: +def _permit( + owner: address, + spender: address, + amount: uint256, + deadline: uint256, + v: uint8, + r: bytes32, + s: bytes32 +) -> bool: assert owner != empty(address), "invalid owner" assert deadline >= block.timestamp, "permit expired" nonce: uint256 = self.nonces[owner] @@ -312,7 +363,10 @@ def _permit(owner: address, spender: address, amount: uint256, deadline: uint256 ) ) ) - assert ecrecover(digest, convert(v, uint256), convert(r, uint256), convert(s, uint256)) == owner, "invalid signature" + assert ecrecover( + digest, convert(v, uint256), convert(r, uint256), convert(s, uint256) + ) == owner, "invalid signature" + self.allowance[owner][spender] = amount self.nonces[owner] = nonce + 1 log Approval(owner, spender, amount) @@ -327,13 +381,19 @@ def _burn_shares(shares: uint256, owner: address): @view @internal def _unlocked_shares() -> uint256: - # To avoid sudden price_per_share spikes, profit must be processed through an unlocking period. - # The mechanism involves shares to be minted to the vault which are unlocked gradually over time. - # Shares that have been locked are gradually unlocked over profit_max_unlock_time seconds + """ + Returns the amount of shares that have been unlocked. + To avoid sudden price_per_share spikes, profits must be processed + through an unlocking period. The mechanism involves shares to be + minted to the vault which are unlocked gradually over time. Shares + that have been locked are gradually unlocked over profit_max_unlock_time. + """ _full_profit_unlock_date: uint256 = self.full_profit_unlock_date unlocked_shares: uint256 = 0 if _full_profit_unlock_date > block.timestamp: + # If we have not fully unlocked, we need to calculate how much has been. unlocked_shares = self.profit_unlocking_rate * (block.timestamp - self.last_profit_update) / MAX_BPS_EXTENDED + elif _full_profit_unlock_date != 0: # All shares have been unlocked unlocked_shares = self.balance_of[self] @@ -344,15 +404,19 @@ def _unlocked_shares() -> uint256: @view @internal def _total_supply() -> uint256: + # Need to account for the shares issued to the vault that have unlockded. return self.total_supply - self._unlocked_shares() @internal def _burn_unlocked_shares(): """ Burns shares that have been unlocked since last update. - In case the full unlocking period has passed, it stops the unlocking + In case the full unlocking period has passed, it stops the unlocking. """ + # Get the amount of shares that have unlocked unlocked_shares: uint256 = self._unlocked_shares() + + # IF 0 theres nothing to do. if unlocked_shares == 0: return @@ -360,6 +424,7 @@ def _burn_unlocked_shares(): if self.full_profit_unlock_date > block.timestamp: self.last_profit_update = block.timestamp + # Burn the shares unlocked. self._burn_shares(unlocked_shares, self) @view @@ -473,14 +538,15 @@ def _issue_shares(shares: uint256, recipient: address): @internal def _issue_shares_for_amount(amount: uint256, recipient: address) -> uint256: """ - Issues shares that are worth 'amount' in the underlying token (asset) + 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) + 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: @@ -503,7 +569,10 @@ def _issue_shares_for_amount(amount: uint256, recipient: address) -> uint256: ## ERC4626 ## @view @internal -def _max_deposit(receiver: address) -> uint256: +def _max_deposit(receiver: address) -> uint256: + if receiver in [empty(address), self]: + return 0 + _total_assets: uint256 = self._total_assets() _deposit_limit: uint256 = self.deposit_limit if (_total_assets >= _deposit_limit): @@ -523,19 +592,26 @@ def _max_withdraw(owner: address) -> uint256: @internal def _deposit(sender: address, recipient: address, assets: uint256) -> uint256: + """ + Used for `deposit` and `mint` calls to transfer the amoutn 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 recipient not in [self, empty(address)], "invalid recipient" - assert self._total_assets() + assets <= self.deposit_limit, "exceed deposit limit" + # Transfer the tokens to the vault first. self._erc20_safe_transfer_from(ASSET.address, 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 @view @@ -546,7 +622,9 @@ 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 1000 tokens, the losses that he will take are 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) @@ -554,14 +632,34 @@ def _assess_share_of_unrealised_losses(strategy: address, assets_needed: uint256 if strategy_assets >= strategy_current_debt or strategy_current_debt == 0: return 0 - # user will withdraw assets_to_withdraw divided by loss ratio (strategy_assets / strategy_current_debt - 1) - # but will only receive assets_to_withdraw - # NOTE: if there are unrealised losses, the user will take his share + # Users will withdraw assets_to_withdraw divided by loss ratio (strategy_assets / strategy_current_debt - 1), + # but will only receive assets_to_withdraw. + # NOTE: If there are unrealised losses, the user will take his share. losses_user_share: uint256 = assets_needed - assets_needed * strategy_assets / strategy_current_debt + return losses_user_share @internal -def _redeem(sender: address, receiver: address, owner: address, shares_to_burn: uint256, strategies: DynArray[address, MAX_QUEUE]) -> uint256: +def _redeem( + sender: address, + receiver: address, + owner: address, + shares_to_burn: uint256, + strategies: DynArray[address, MAX_QUEUE] +) -> uint256: + """ + This will attempt to free up the full amount of assets equivalant to + `shares_to_burn` and transfer them to the `receiver`. If the vault does + not have enough idle funds it will go through any strategies provided by + either the withdrawer or the queue_manaager to free up enough funds to + service the request. + + The vault will attempt to account for any unrealized losses taken on from + strategies since their respective last reports. + + Any losses realized during the withdraw from a strategy will be passed on + to the user that is redeeming their vault shares. + """ shares: uint256 = shares_to_burn shares_balance: uint256 = self.balance_of[owner] @@ -571,14 +669,17 @@ def _redeem(sender: address, receiver: address, owner: address, shares_to_burn: if sender != owner: self._spend_allowance(owner, sender, shares_to_burn) + # The amount of the underlying token to withdraw. requested_assets: uint256 = self._convert_to_assets(shares, Rounding.ROUND_DOWN) # 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 in the input + # If there are not enough assets in the Vault contract, we try to free + # funds from strategies. if requested_assets > curr_total_idle: + # Cache the input withdrawal queue. _strategies: DynArray[address, MAX_QUEUE] = strategies # If no queue was passed. @@ -589,27 +690,34 @@ def _redeem(sender: address, receiver: address, owner: address, shares_to_burn: # load to memory to save gas curr_total_debt: uint256 = self.total_debt - # Withdraw from strategies if insufficient total idle + # Withdraw from strategies only what idle doesnt cover. + # `assets_needed` is the total amount we need to fill the request. assets_needed: uint256 = requested_assets - curr_total_idle + # `assets_to_withdraw` is the amount to request from the current strategy. assets_to_withdraw: uint256 = 0 - # NOTE: to compare against real withdrawals from strategies + # To compare against real withdrawals from strategies previous_balance: uint256 = ASSET.balanceOf(self) + for strategy in _strategies: + # Make sure we have a valid strategy. assert self.strategies[strategy].activation != 0, "inactive strategy" + # How much should the strategy have. current_debt: uint256 = self.strategies[strategy].current_debt # What is the max amount to withdraw from this strategy. assets_to_withdraw = min(assets_needed, current_debt) - # Cache max_withdraw for use if unrealized loss > 0 + # Cache max_withdraw now for use if unrealized loss > 0 max_withdraw: uint256 = IStrategy(strategy).maxWithdraw(self) # CHECK FOR UNREALISED LOSSES - # If unrealised losses > 0, then the user will take the proportional share and realize it (required to avoid users withdrawing from lossy strategies) - # 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) + # If unrealised losses > 0, then the user will take the proportional share + # and realize it (required to avoid users withdrawing from lossy strategies). + # 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) if unrealised_losses_share > 0: # If max withdraw is limiting the amount to pull, we need to adjust the portion of @@ -630,9 +738,10 @@ def _redeem(sender: address, receiver: address, owner: address, shares_to_burn: assets_needed -= unrealised_losses_share curr_total_debt -= unrealised_losses_share - # If max withdraw is 0 and unrealised loss is still > 0 then the strategy likely realized - # a 100% loss and we will need to realize that loss before moving on. + # If max withdraw is 0 and unrealised loss is still > 0 then the strategy likely + # realized a 100% loss and we will need to realize that loss before moving on. if max_withdraw == 0 and unrealised_losses_share > 0: + # Adjust the strategy debt accordingly. new_debt: uint256 = current_debt - unrealised_losses_share # Update strategies storage @@ -640,7 +749,7 @@ def _redeem(sender: address, receiver: address, owner: address, shares_to_burn: # Log the debt update log DebtUpdated(strategy, current_debt, new_debt) - # Adjust based on the max withdraw of the strategy + # Adjust based on the max withdraw of the strategy. assets_to_withdraw = min(assets_to_withdraw, max_withdraw) # Can't withdraw 0. @@ -651,13 +760,13 @@ def _redeem(sender: address, receiver: address, owner: address, shares_to_burn: IStrategy(strategy).withdraw(assets_to_withdraw, self, self) post_balance: uint256 = ASSET.balanceOf(self) - # If we have not received what we expected, we consider the difference a loss loss: uint256 = 0 + # If we have not received what we expected, we consider the difference a loss. if(previous_balance + assets_to_withdraw > post_balance): loss = previous_balance + assets_to_withdraw - post_balance # NOTE: strategy's debt decreases by the full amount but the total idle increases - # by the actual amount only (as the difference is considered lost) + # by the actual amount only (as the difference is considered lost). curr_total_idle += (assets_to_withdraw - loss) requested_assets -= loss curr_total_debt -= assets_to_withdraw @@ -670,25 +779,26 @@ def _redeem(sender: address, receiver: address, owner: address, shares_to_burn: # Log the debt update log DebtUpdated(strategy, current_debt, new_debt) - # NOTE: the user will receive less tokens (the rest were lost) - # break if we have enough total idle to serve initial request + # Break if we have enough total idle to serve initial request. if requested_assets <= curr_total_idle: break - # NOTE: we update the previous_balance variable here to save gas in next iteration + # We update the previous_balance variable here to save gas in next iteration. previous_balance = post_balance # Reduce what we still need. assets_needed -= assets_to_withdraw - # if we exhaust the queue and still have insufficient total idle, revert + # 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 + # Commit memory to storage. self.total_debt = curr_total_debt + # First burn the corresponding shares from the redeemer. self._burn_shares(shares, owner) - # commit memory to storage + # Commit memory to storage. self.total_idle = curr_total_idle - requested_assets + # Transfer the requested amount to the receiver. self._erc20_safe_transfer(ASSET.address, receiver, requested_assets) log Withdraw(sender, receiver, owner, requested_assets, shares) @@ -701,6 +811,7 @@ def _add_strategy(new_strategy: address): assert IStrategy(new_strategy).asset() == ASSET.address, "invalid asset" assert self.strategies[new_strategy].activation == 0, "strategy already active" + # Add the new strategy to the mapping. self.strategies[new_strategy] = StrategyParams({ activation: block.timestamp, last_report: block.timestamp, @@ -717,15 +828,20 @@ def _add_strategy(new_strategy: address): @internal def _revoke_strategy(strategy: address, force: bool=False): assert self.strategies[strategy].activation != 0, "strategy not active" + + # If force revoking a strategy, it will cause a loss. loss: uint256 = 0 if self.strategies[strategy].current_debt != 0: assert force, "strategy has debt" + # Vault realizes the full loss of outstanding debt. loss = self.strategies[strategy].current_debt + # Adjust total vault debt. self.total_debt -= loss + log StrategyReported(strategy, 0, loss, 0, 0, 0, 0) - # NOTE: strategy params are set to 0 (WARNING: it can be readded) + # Set strategy params all back to 0 (WARNING: it can be readded). self.strategies[strategy] = StrategyParams({ activation: 0, last_report: 0, @@ -736,12 +852,11 @@ def _revoke_strategy(strategy: address, force: bool=False): # Remove strategy if it is in the default queue. new_queue: DynArray[address, MAX_QUEUE] = [] for _strategy in self.default_queue: - if _strategy == strategy: - continue - - new_queue.append(_strategy) + # Add all strategies to the new queue besides the one revoked. + if _strategy != strategy: + new_queue.append(_strategy) - # Set the default queue to our updated queue + # Set the default queue to our updated queue. self.default_queue = new_queue log StrategyChanged(strategy, StrategyChangeType.REVOKED) @@ -750,42 +865,45 @@ def _revoke_strategy(strategy: address, force: bool=False): @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 to strategy's max_debt. - This function will compare the current debt with the target debt and will take funds or deposit new + The vault will rebalance the debt vs target debt. Target debt must be + smaller or equal to strategy's 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 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. + The strategy can require a maximum amount of funds that it wants to receive + to invest. The strategy can also reject freeing funds if they are locked. """ + # How much we want the strategy to have. new_debt: uint256 = target_debt - + # How much the strategy currently has. current_debt: uint256 = self.strategies[strategy].current_debt + # If the vault is shutdown we can only pull funds. if self.shutdown: new_debt = 0 assert new_debt != current_debt, "new debt equals current debt" if current_debt > new_debt: - # reduce debt + # Reduce debt. assets_to_withdraw: uint256 = current_debt - new_debt - # ensure we always have minimum_total_idle when updating debt + # Ensure we always have minimum_total_idle when updating debt. minimum_total_idle: uint256 = self.minimum_total_idle total_idle: uint256 = self.total_idle # Respect minimum total idle in vault if total_idle + assets_to_withdraw < minimum_total_idle: assets_to_withdraw = minimum_total_idle - total_idle + # Cant withdraw more than the strategy has. if assets_to_withdraw > current_debt: assets_to_withdraw = current_debt + # Check how much we are able to withdraw. withdrawable: uint256 = IStrategy(strategy).maxWithdraw(self) assert withdrawable != 0, "nothing to withdraw" - # if insufficient withdrawable, withdraw what we can + # If insufficient withdrawable, withdraw what we can. if withdrawable < assets_to_withdraw: assets_to_withdraw = withdrawable @@ -793,58 +911,72 @@ def _update_debt(strategy: address, target_debt: uint256) -> uint256: unrealised_losses_share: uint256 = self._assess_share_of_unrealised_losses(strategy, assets_to_withdraw) assert unrealised_losses_share == 0, "strategy has unrealised losses" + # Always check the actual amount withdrawn. pre_balance: uint256 = ASSET.balanceOf(self) IStrategy(strategy).withdraw(assets_to_withdraw, self, self) post_balance: uint256 = ASSET.balanceOf(self) - # making sure we are changing according to the real result no matter what. This will spend more gas but makes it more robust - # also prevents issues from faulty strategy that either under or over delievers 'assets_to_withdraw' + # making sure we are changing according to the real result no matter what. + # This will spend more gas but makes it more robust. Also prevents issues + # from a faulty strategy that either under or over delievers 'assets_to_withdraw' assets_to_withdraw = min(post_balance - pre_balance, current_debt) + # Update storage. self.total_idle += assets_to_withdraw self.total_debt -= assets_to_withdraw new_debt = current_debt - assets_to_withdraw - else: + 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" - # Vault is increasing debt with the strategy by sending more funds + # 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" + # Deposit the difference between desired and current. assets_to_deposit: uint256 = new_debt - current_debt if assets_to_deposit > max_deposit: + # Deposit as much as possible. assets_to_deposit = max_deposit - # take into consideration minimum_total_idle + # Ensure we always have minimum_total_idle when updating debt. 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 insufficient funds to deposit, transfer only what is free. if assets_to_deposit > available_idle: assets_to_deposit = available_idle + # Can't Deposit 0. if assets_to_deposit > 0: + # Approve the strategy to pull only what we are giving it. self._erc20_safe_approve(ASSET.address, strategy, assets_to_deposit) + + # Always update based on actual amounts deposited. pre_balance: uint256 = ASSET.balanceOf(self) IStrategy(strategy).deposit(assets_to_deposit, self) post_balance: uint256 = ASSET.balanceOf(self) + + # Make sure our approval is always back to 0. self._erc20_safe_approve(ASSET.address, strategy, 0) - # making sure we are changing according to the real result no matter what. - # This will spend more gas but makes it more robust + # Making sure we are changing according to the real result no + # matter what. This will spend more gas but makes it more robust. assets_to_deposit = pre_balance - post_balance + # Update storage. self.total_idle -= assets_to_deposit self.total_debt += assets_to_deposit new_debt = current_debt + assets_to_deposit - # commit memory to storage + # Commit memory to storage. self.strategies[strategy].current_debt = new_debt log DebtUpdated(strategy, current_debt, new_debt) @@ -854,33 +986,45 @@ def _update_debt(strategy: address, target_debt: uint256) -> uint256: @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 owes less than it currently has, it means it has had a profit - Else (assets < debt) it has had a loss + Processing a report means comparing the debt that the strategy has taken + with the current amount of funds it is reporting. If the strategy owes + less than it currently has, 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. - 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 vaults + profit_max_unlock_time seconds. Losses will be taken immediately, first from the + profit buffer (avoiding an impact in pps), then will reduce pps. - The profit will be distributed following a smooth curve over the next profit_max_unlock_time seconds. - Losses will be taken immediately, first from the profit buffer (avoiding an impact in pps), then will reduce pps + 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 needs to assess - # Using strategy shares because some may be a ERC4626 vault + # Burn shares that have been unlocked since the last update + self._burn_unlocked_shares() + + # Vault asseses 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 - - # Burn shares that have been unlocked since the last update - self._burn_unlocked_shares() gain: uint256 = 0 loss: uint256 = 0 + # Compare reported assets vs. the current debt. if total_assets > current_debt: + # We have a gain. gain = total_assets - current_debt else: + # We have a loss. loss = current_debt - total_assets # For Accountant fee assessment. @@ -898,25 +1042,31 @@ def _process_report(strategy: address) -> (uint256, uint256): # Protocol fees will be 0 if accountant fees are 0. if total_fees > 0: protocol_fee_bps: uint16 = 0 + # Get the config for this vault. protocol_fee_bps, protocol_fee_recipient = IFactory(FACTORY).protocol_fee_config() if(protocol_fee_bps > 0): # Protocol fees are a percent of the fees the accountant is charging. protocol_fees = total_fees * convert(protocol_fee_bps, uint256) / MAX_BPS - # We calculate the amount of shares that could be insta unlocked to avoid pps changes + # `shares_to_burn` is derived from amounts that would reduce the vaullts PPS. # NOTE: this needs to be done before any pps changes shares_to_burn: uint256 = 0 accountant_fees_shares: uint256 = 0 protocol_fees_shares: uint256 = 0 + # Only need to burn shares if there is a loss or fees. if loss + total_fees > 0: + # The amount of shares we will want to burn to offset losses and fees. shares_to_burn += self._convert_to_shares(loss + total_fees, Rounding.ROUND_UP) - # Vault calculates the amount of shares to mint as fees before changing totalAssets / totalSupply + + # Vault calculates the amount of shares to mint as fees before changing totalAssets / totalSupply. if total_fees > 0: + # Accountant fees are total fees - protocol fees. accountant_fees_shares = self._convert_to_shares(total_fees - protocol_fees, Rounding.ROUND_DOWN) if protocol_fees > 0: protocol_fees_shares = self._convert_to_shares(protocol_fees, Rounding.ROUND_DOWN) + # Shares to lock is any amounts that would otherwise increase the vaults PPS. newly_locked_shares: uint256 = 0 if total_refunds > 0: # Make sure we have enough approval and enough asset to pull. @@ -928,12 +1078,13 @@ def _process_report(strategy: address) -> (uint256, uint256): # Mint new shares corresponding to the refunded assets to self. newly_locked_shares += self._issue_shares_for_amount(total_refunds, self) + # Record any reported gains. if gain > 0: # NOTE: this will increase total_assets self.strategies[strategy].current_debt += gain self.total_debt += gain - # NOTE: vault will issue shares worth the profit to avoid instant pps change + # Vault will issue shares worth the profit to itself to lock avoid instant pps change. newly_locked_shares += self._issue_shares_for_amount(gain, self) # Strategy is reporting a loss @@ -943,28 +1094,31 @@ def _process_report(strategy: address) -> (uint256, uint256): # NOTE: should be precise (no new unlocked shares due to above's burn of shares) # newly_locked_shares have already been minted / transfered to the vault, so they need to be substracted - # no risk of underflow because they have just been minted + # no risk of underflow because they have just been minted. previously_locked_shares: uint256 = self.balance_of[self] - newly_locked_shares # Now that pps has updated, we can burn the shares we intended to burn as a result of losses/fees. # NOTE: If a value reduction (losses / fees) has occured, prioritize burning locked profit to avoid # negative impact on price per share. Price per share is reduced only if losses exceed locked value. if shares_to_burn > 0: + # Cant burn more than the vault owns. shares_to_burn = min(shares_to_burn, previously_locked_shares + newly_locked_shares) self._burn_shares(shares_to_burn, self) - # we burn first the newly locked shares, then the previously locked shares + + # We burn first the newly locked shares, then the previously locked shares. shares_not_to_lock: uint256 = min(shares_to_burn, newly_locked_shares) + # Reduce the amounts to lock by how much we burned newly_locked_shares -= shares_not_to_lock previously_locked_shares -= (shares_to_burn - shares_not_to_lock) - # issue shares that were calculated above + # Issue shares for fees that were calculated above if applicable. if accountant_fees_shares > 0: self._issue_shares(accountant_fees_shares, accountant) if protocol_fees_shares > 0: self._issue_shares(protocol_fees_shares, protocol_fee_recipient) - # Update unlocking rate and time to fully unlocked + # Update unlocking rate and time to fully unlocked. total_locked_shares: uint256 = previously_locked_shares + newly_locked_shares if total_locked_shares > 0: previously_locked_time: uint256 = 0 @@ -985,12 +1139,14 @@ def _process_report(strategy: address) -> (uint256, uint256): self.last_profit_update = block.timestamp else: - # NOTE: only setting this to 0 will turn in the desired effect, no need to update last_profit_update or full_profit_unlock_date + # NOTE: only setting this to 0 will turn in the desired effect, no need + # to update last_profit_update or full_profit_unlock_date self.profit_unlocking_rate = 0 + # Record the report of profit timestamp. self.strategies[strategy].last_report = block.timestamp - # We have to recalculate the fees paid for cases with an overall loss + # We have to recalculate the fees paid for cases with an overall loss. log StrategyReported( strategy, gain, @@ -1000,8 +1156,8 @@ def _process_report(strategy: address) -> (uint256, uint256): self._convert_to_assets(protocol_fees_shares + accountant_fees_shares, Rounding.ROUND_DOWN), total_refunds ) - return (gain, loss) + return (gain, loss) # SETTERS # @external @@ -1012,6 +1168,7 @@ def set_accountant(new_accountant: address): """ self._enforce_role(msg.sender, Roles.ACCOUNTANT_MANAGER) self.accountant = new_accountant + log UpdateAccountant(new_accountant) @external @@ -1036,12 +1193,13 @@ def set_default_queue(new_default_queue: DynArray[address, MAX_QUEUE]): def set_deposit_limit(deposit_limit: uint256): """ @notice Set the new deposit limit. - @dev can not be changed if shutdown. + @dev Can not be changed if shutdown. @param deposit_limit The new deposit limit. """ assert self.shutdown == False # Dev: shutdown self._enforce_role(msg.sender, Roles.DEPOSIT_LIMIT_MANAGER) self.deposit_limit = deposit_limit + log UpdateDepositLimit(deposit_limit) @external @@ -1052,6 +1210,7 @@ def set_minimum_total_idle(minimum_total_idle: uint256): """ self._enforce_role(msg.sender, Roles.MINIMUM_IDLE_MANAGER) self.minimum_total_idle = minimum_total_idle + log UpdateMinimumTotalIdle(minimum_total_idle) @external @@ -1072,11 +1231,13 @@ def set_profit_max_unlock_time(new_profit_max_unlock_time: uint256): assert new_profit_max_unlock_time <= 31_556_952, "profit unlock time too long" self.profit_max_unlock_time = new_profit_max_unlock_time + log UpdateProfitMaxUnlockTime(new_profit_max_unlock_time) # ROLE MANAGEMENT # @internal def _enforce_role(account: address, role: Roles): + # Make sure the sender either holds the role or it has been opened. assert role in self.roles[account] or self.open_roles[role], "not allowed" @external @@ -1088,32 +1249,38 @@ def set_role(account: address, role: Roles): """ assert msg.sender == self.role_manager self.roles[account] = role + log RoleSet(account, role) @external def set_open_role(role: Roles): """ - @notice Set the role to be open. + @notice Set a role to be open. @param role The role to set. """ assert msg.sender == self.role_manager self.open_roles[role] = True + log RoleStatusChanged(role, RoleStatusChange.OPENED) @external def close_open_role(role: Roles): """ - @notice Close the role. + @notice Close a opened role. @param role The role to close. """ assert msg.sender == self.role_manager self.open_roles[role] = False + log RoleStatusChanged(role, RoleStatusChange.CLOSED) @external def transfer_role_manager(role_manager: address): """ - @notice Transfer the role manager to a new address. + @notice Step 1 of 2 in order to transfer the + role manager to a new address. This will set + the future_role_manager. Which will then need + to be accepted by the new manager. @param role_manager The new role manager address. """ assert msg.sender == self.role_manager @@ -1127,6 +1294,7 @@ def accept_role_manager(): assert msg.sender == self.future_role_manager self.role_manager = msg.sender self.future_role_manager = empty(address) + log UpdateRoleManager(msg.sender) # VAULT STATUS VIEWS @@ -1134,8 +1302,8 @@ def accept_role_manager(): @external def unlocked_shares() -> uint256: """ - @notice Get the amount of shares that are not locked. - @return The amount of shares that are not locked. + @notice Get the amount of shares that have been unlocked. + @return The amount of shares that are have been unlocked. """ return self._unlocked_shares() @@ -1143,9 +1311,9 @@ def unlocked_shares() -> uint256: @external def pricePerShare() -> uint256: """ - @notice Get the price per share. - @dev This value offers limited precision. Integrations the require - exact precision should use convertToAssets or convertToShares instead. + @notice Get the price per share (pps) of the vault. + @dev This value offers limited precision. Integrations that require + exact precision should use convertToAssets or convertToShares instead. @return The price per share. """ return self._convert_to_assets(10 ** DECIMALS, Rounding.ROUND_DOWN) @@ -1199,22 +1367,19 @@ def buy_debt(strategy: address, amount: uint256): self._enforce_role(msg.sender, Roles.DEBT_PURCHASER) assert self.strategies[strategy].activation != 0, "not active" - # cache the current debt + # Cache the current debt. current_debt: uint256 = self.strategies[strategy].current_debt assert current_debt > 0, "nothing to buy" assert amount > 0, "nothing to buy with" - # Get the current shares value for the amount + # Get the current shares value for the amount. shares: uint256 = IStrategy(strategy).convertToShares(amount) + assert shares > 0, "can't buy 0" assert shares <= IStrategy(strategy).balanceOf(self), "not enough shares" - before_balance: uint256 = ASSET.balanceOf(self) self._erc20_safe_transfer_from(ASSET.address, msg.sender, self, amount) - after_balance: uint256 = ASSET.balanceOf(self) - - assert after_balance - before_balance >= amount # Adjust if needed to not underflow on math bought: uint256 = min(current_debt, amount) @@ -1231,8 +1396,8 @@ def buy_debt(strategy: address, amount: uint256): # Transfer the strategies shares out. self._erc20_safe_transfer(strategy, msg.sender, shares) - log DebtPurchased(strategy, bought) + log DebtPurchased(strategy, bought) ## STRATEGY MANAGEMENT ## @external @@ -1257,11 +1422,12 @@ def revoke_strategy(strategy: address): def force_revoke_strategy(strategy: address): """ @notice Force revoke a strategy. + @dev The vault will remove the inputed strategy and write off any debt left + in it as a loss. This function is a dangerous function as it can force a + strategy to take a loss. All possible assets should be removed from the + strategy first via update_debt. If a strategy is removed erroneously it + can be re-added and the loss will be credited as profit. Fees will apply. @param strategy The strategy to force revoke. - @dev The vault will remove the inputed strategy and write off any debt left in it as loss. - This function is a dangerous function as it can force a strategy to take a loss. - All possible assets should be removed from the strategy first via update_debt - Note that if a strategy is removed erroneously it can be re-added and the loss will be credited as profit. Fees will apply """ self._enforce_role(msg.sender, Roles.FORCE_REVOKE_MANAGER) self._revoke_strategy(strategy, True) @@ -1277,6 +1443,7 @@ def update_max_debt_for_strategy(strategy: address, new_max_debt: uint256): self._enforce_role(msg.sender, Roles.MAX_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 @@ -1339,7 +1506,12 @@ def mint(shares: uint256, receiver: address) -> uint256: @external @nonreentrant("lock") -def withdraw(assets: uint256, receiver: address, owner: address, strategies: DynArray[address, MAX_QUEUE] = []) -> uint256: +def withdraw( + assets: uint256, + receiver: address, + owner: address, + strategies: DynArray[address, MAX_QUEUE] = [] +) -> uint256: """ @notice Withdraw an amount of asset to `receiver` burning `owner`s shares. @param assets The amount of asset to withdraw. @@ -1354,7 +1526,12 @@ def withdraw(assets: uint256, receiver: address, owner: address, strategies: Dyn @external @nonreentrant("lock") -def redeem(shares: uint256, receiver: address, owner: address, strategies: DynArray[address, MAX_QUEUE] = []) -> uint256: +def redeem( + shares: uint256, + receiver: address, + owner: address, + strategies: DynArray[address, MAX_QUEUE] = [] +) -> uint256: """ @notice Redeems an amount of shares of `owners` shares sending funds to `receiver`. @param shares The amount of shares to burn. @@ -1422,7 +1599,15 @@ def decreaseAllowance(spender: address, amount: uint256) -> bool: return self._decrease_allowance(msg.sender, spender, amount) @external -def permit(owner: address, spender: address, amount: uint256, deadline: uint256, v: uint8, r: bytes32, s: bytes32) -> bool: +def permit( + owner: address, + spender: address, + amount: uint256, + deadline: uint256, + v: uint8, + r: bytes32, + s: bytes32 +) -> bool: """ @notice Approve an address to spend the vault's shares. @param owner The address to approve. @@ -1445,7 +1630,9 @@ def balanceOf(addr: address) -> uint256: @return The balance of the user. """ if(addr == self): - return self.balance_of[addr] - self._unlocked_shares() + # If the address is the vault, account for locked shares. + return self.balance_of[addr] - self._unlocked_shares() + return self.balance_of[addr] @view