From 50adff2e22ebc682e622cd3e1d51bcff7731fd94 Mon Sep 17 00:00:00 2001 From: Schlagonia Date: Mon, 15 Jan 2024 12:55:35 -0700 Subject: [PATCH 1/5] build: only burn or mint --- contracts/VaultV3.vy | 71 +++++++++++++++++++++----------------------- 1 file changed, 34 insertions(+), 37 deletions(-) diff --git a/contracts/VaultV3.vy b/contracts/VaultV3.vy index b5576d31..956aa1cd 100644 --- a/contracts/VaultV3.vy +++ b/contracts/VaultV3.vy @@ -1167,9 +1167,6 @@ def _process_report(strategy: address) -> (uint256, uint256): # Make sure we have a valid strategy. assert self.strategies[strategy].activation != 0, "inactive strategy" - # Burn shares that have been unlocked since the last update - self._burn_unlocked_shares() - # 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. @@ -1199,24 +1196,30 @@ def _process_report(strategy: address) -> (uint256, uint256): if accountant != empty(address): total_fees, total_refunds = IAccountant(accountant).report(strategy, gain, loss) + if total_refunds > 0: + # Make sure we have enough approval and enough asset to pull. + total_refunds = min(total_refunds, min(ASSET.balanceOf(accountant), ASSET.allowance(accountant, self))) + # For Protocol fee assessment. protocol_fee_bps: uint16 = 0 protocol_fee_recipient: address = empty(address) # `shares_to_burn` is derived from amounts that would reduce the vaults PPS. # NOTE: this needs to be done before any pps changes - shares_to_burn: uint256 = 0 + # Burn shares that have been unlocked since the last update + self._burn_unlocked_shares() total_fees_shares: uint256 = 0 protocol_fees_shares: uint256 = 0 + shares_to_burn: 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. + # The amount of shares we will want to burn to offset losses. 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. if total_fees > 0: # Get the total amount shares to issue for the fees. - total_fees_shares = self._convert_to_shares(total_fees, Rounding.ROUND_DOWN) + total_fees_shares = shares_to_burn * total_fees / (loss + total_fees) # Get the config for this vault. protocol_fee_bps, protocol_fee_recipient = IFactory(FACTORY).protocol_fee_config() @@ -1227,10 +1230,27 @@ def _process_report(strategy: address) -> (uint256, uint256): protocol_fees_shares = total_fees_shares * convert(protocol_fee_bps, uint256) / MAX_BPS # Shares to lock is any amounts that would otherwise increase the vaults PPS. - newly_locked_shares: uint256 = 0 + shares_to_lock: uint256 = 0 + profit_max_unlock_time: uint256 = self.profit_max_unlock_time + # Mint anything we are locking to the vault. + if gain + total_refunds > 0 and profit_max_unlock_time != 0: + shares_to_lock = self._convert_to_shares(gain + total_refunds, Rounding.ROUND_DOWN) + + total_locked_shares: uint256 = self.balance_of[self] + # Either burn or mint but not both. + if shares_to_burn > shares_to_lock: + # Net burning shares. + shares_to_burn = min(shares_to_burn - shares_to_lock, total_locked_shares) + self._burn_shares(shares_to_burn, self) + shares_to_lock = 0 + + elif shares_to_lock > shares_to_burn: + # Net issuing of shares. + shares_to_lock = unsafe_sub(shares_to_lock, shares_to_burn) + self._issue_shares(shares_to_lock, self) + + if total_refunds > 0: - # Make sure we have enough approval and enough asset to pull. - total_refunds = min(total_refunds, min(ASSET.balanceOf(accountant), ASSET.allowance(accountant, self))) # Transfer the refunded amount of asset to the vault. self._erc20_safe_transfer_from(ASSET.address, accountant, self, total_refunds) # Update storage to increase total assets. @@ -1243,36 +1263,12 @@ def _process_report(strategy: address) -> (uint256, uint256): self.strategies[strategy].current_debt = current_debt self.total_debt += gain - profit_max_unlock_time: uint256 = self.profit_max_unlock_time - # Mint anything we are locking to the vault. - if gain + total_refunds > 0 and profit_max_unlock_time != 0: - newly_locked_shares = self._issue_shares_for_amount(gain + total_refunds, self) - # Strategy is reporting a loss if loss > 0: current_debt = unsafe_sub(current_debt, loss) self.strategies[strategy].current_debt = current_debt self.total_debt -= loss - # NOTE: should be precise (no new unlocked shares due to above's burn of shares) - # newly_locked_shares have already been minted / transferred to the vault, so they need to be subtracted - # 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 occurred, 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. - 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 for fees that were calculated above if applicable. if total_fees_shares > 0: # Accountant fees are (total_fees - protocol_fees). @@ -1282,7 +1278,7 @@ def _process_report(strategy: address) -> (uint256, uint256): self._issue_shares(protocol_fees_shares, protocol_fee_recipient) # Update unlocking rate and time to fully unlocked. - total_locked_shares: uint256 = previously_locked_shares + newly_locked_shares + total_locked_shares = self.balance_of[self] if total_locked_shares > 0: previously_locked_time: uint256 = 0 _full_profit_unlock_date: uint256 = self.full_profit_unlock_date @@ -1290,15 +1286,16 @@ def _process_report(strategy: address) -> (uint256, uint256): if _full_profit_unlock_date > block.timestamp: # There will only be previously locked shares if time remains. # We calculate this here since it will not occur every time we lock shares. - previously_locked_time = previously_locked_shares * (_full_profit_unlock_date - block.timestamp) + previously_locked_time = (total_locked_shares - shares_to_lock) * (_full_profit_unlock_date - block.timestamp) # new_profit_locking_period is a weighted average between the remaining time of the previously locked shares and the profit_max_unlock_time - new_profit_locking_period: uint256 = (previously_locked_time + newly_locked_shares * profit_max_unlock_time) / total_locked_shares + new_profit_locking_period: uint256 = (previously_locked_time + shares_to_lock * profit_max_unlock_time) / total_locked_shares # Calculate how many shares unlock per second. self.profit_unlocking_rate = total_locked_shares * MAX_BPS_EXTENDED / new_profit_locking_period # Calculate how long until the full amount of shares is unlocked. self.full_profit_unlock_date = block.timestamp + new_profit_locking_period - + # Update the last profitable report timestamp. + self.last_profit_update = block.timestamp else: # NOTE: only setting this to the 0 will turn in the desired effect, # no need to update profit_unlocking_rate From dc674b6502c83917e948a93ae17139d5cfaa180c Mon Sep 17 00:00:00 2001 From: Schlagonia Date: Mon, 15 Jan 2024 20:20:10 -0700 Subject: [PATCH 2/5] build: target end supply --- contracts/VaultV3.vy | 50 +++++++++++++++++++++++++------------------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/contracts/VaultV3.vy b/contracts/VaultV3.vy index 956aa1cd..379dc67f 100644 --- a/contracts/VaultV3.vy +++ b/contracts/VaultV3.vy @@ -1200,21 +1200,18 @@ def _process_report(strategy: address) -> (uint256, uint256): # Make sure we have enough approval and enough asset to pull. total_refunds = min(total_refunds, min(ASSET.balanceOf(accountant), ASSET.allowance(accountant, self))) - # For Protocol fee assessment. - protocol_fee_bps: uint16 = 0 - protocol_fee_recipient: address = empty(address) - # `shares_to_burn` is derived from amounts that would reduce the vaults PPS. # NOTE: this needs to be done before any pps changes - # Burn shares that have been unlocked since the last update - self._burn_unlocked_shares() + shares_to_burn: uint256 = 0 total_fees_shares: uint256 = 0 + # For Protocol fee assessment. + protocol_fee_bps: uint16 = 0 protocol_fees_shares: uint256 = 0 - shares_to_burn: uint256 = 0 + protocol_fee_recipient: address = empty(address) # 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. - shares_to_burn += self._convert_to_shares(loss + total_fees, Rounding.ROUND_UP) + 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. if total_fees > 0: @@ -1235,21 +1232,32 @@ def _process_report(strategy: address) -> (uint256, uint256): # Mint anything we are locking to the vault. if gain + total_refunds > 0 and profit_max_unlock_time != 0: shares_to_lock = self._convert_to_shares(gain + total_refunds, Rounding.ROUND_DOWN) - - total_locked_shares: uint256 = self.balance_of[self] - # Either burn or mint but not both. - if shares_to_burn > shares_to_lock: - # Net burning shares. - shares_to_burn = min(shares_to_burn - shares_to_lock, total_locked_shares) - self._burn_shares(shares_to_burn, self) - shares_to_lock = 0 - - elif shares_to_lock > shares_to_burn: - # Net issuing of shares. - shares_to_lock = unsafe_sub(shares_to_lock, shares_to_burn) - self._issue_shares(shares_to_lock, self) + # The total current supply including locked shares. + total_supply: uint256 = self.total_supply + # The total shares the vault currently owns. + total_locked_shares: uint256 = self.balance_of[self] + # Get the expected end amount of shares after all accounting. + ending_supply: uint256 = total_supply + shares_to_lock - shares_to_burn - self._unlocked_shares() + + # If we will end with more tokens than we have now. + if ending_supply > total_supply: + # Issue the difference. + self._issue_shares(unsafe_sub(ending_supply, total_supply), self) + + # Else we need to burn shares. + elif total_supply > ending_supply: + # Can't burn more than the vault owns. + to_burn: uint256 = min(unsafe_sub(total_supply, ending_supply), total_locked_shares) + self._burn_shares(to_burn, self) + + # Adjust the amount to lock for this period. + if shares_to_lock > shares_to_burn: + shares_to_lock -= shares_to_burn + else: + shares_to_burn = 0 + # Pull refunds if total_refunds > 0: # Transfer the refunded amount of asset to the vault. self._erc20_safe_transfer_from(ASSET.address, accountant, self, total_refunds) From 67dc128a7b651cc065ecb056398409ac7b5c0caa Mon Sep 17 00:00:00 2001 From: Schlagonia Date: Tue, 16 Jan 2024 09:02:20 -0700 Subject: [PATCH 3/5] chore: comments --- contracts/VaultV3.vy | 25 ++++--------------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/contracts/VaultV3.vy b/contracts/VaultV3.vy index 379dc67f..2fdf3b96 100644 --- a/contracts/VaultV3.vy +++ b/contracts/VaultV3.vy @@ -409,25 +409,6 @@ def _total_supply() -> uint256: # Need to account for the shares issued to the vault that have unlocked. 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. - """ - # Get the amount of shares that have unlocked - unlocked_shares: uint256 = self._unlocked_shares() - - # Update last profit time no matter what. - self.last_profit_update = block.timestamp - - # IF 0 there's nothing to do. - if unlocked_shares == 0: - return - - # Burn the shares unlocked. - self._burn_shares(unlocked_shares, self) - @view @internal def _total_assets() -> uint256: @@ -1203,6 +1184,7 @@ def _process_report(strategy: address) -> (uint256, uint256): # `shares_to_burn` is derived from amounts that would reduce the vaults PPS. # NOTE: this needs to be done before any pps changes shares_to_burn: uint256 = 0 + # Total fees to charge in shares. total_fees_shares: uint256 = 0 # For Protocol fee assessment. protocol_fee_bps: uint16 = 0 @@ -1210,10 +1192,10 @@ def _process_report(strategy: address) -> (uint256, uint256): protocol_fee_recipient: address = empty(address) # 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. + # 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. + # If we have fees then get the proportional amount of shares. if total_fees > 0: # Get the total amount shares to issue for the fees. total_fees_shares = shares_to_burn * total_fees / (loss + total_fees) @@ -1253,6 +1235,7 @@ def _process_report(strategy: address) -> (uint256, uint256): # Adjust the amount to lock for this period. if shares_to_lock > shares_to_burn: + # Don't lock fees or losses. shares_to_lock -= shares_to_burn else: shares_to_burn = 0 From 077f92ca2424f2f6049e215a801aaf69e4927658 Mon Sep 17 00:00:00 2001 From: Schlagonia Date: Fri, 19 Jan 2024 22:08:05 -0700 Subject: [PATCH 4/5] fix: comments --- contracts/VaultV3.vy | 42 ++++++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/contracts/VaultV3.vy b/contracts/VaultV3.vy index 2fdf3b96..5dba1239 100644 --- a/contracts/VaultV3.vy +++ b/contracts/VaultV3.vy @@ -1160,6 +1160,8 @@ def _process_report(strategy: address) -> (uint256, uint256): gain: uint256 = 0 loss: uint256 = 0 + ### Asses Gain or Loss ### + # Compare reported assets vs. the current debt. if total_assets > current_debt: # We have a gain. @@ -1167,13 +1169,14 @@ def _process_report(strategy: address) -> (uint256, uint256): else: # We have a loss. loss = unsafe_sub(current_debt, total_assets) + + ### Asses Fees and Refunds ### # For Accountant fee assessment. total_fees: uint256 = 0 total_refunds: uint256 = 0 - - accountant: address = self.accountant # If accountant is not set, fees and refunds remain unchanged. + accountant: address = self.accountant if accountant != empty(address): total_fees, total_refunds = IAccountant(accountant).report(strategy, gain, loss) @@ -1181,26 +1184,26 @@ def _process_report(strategy: address) -> (uint256, uint256): # Make sure we have enough approval and enough asset to pull. total_refunds = min(total_refunds, min(ASSET.balanceOf(accountant), ASSET.allowance(accountant, self))) - # `shares_to_burn` is derived from amounts that would reduce the vaults PPS. - # NOTE: this needs to be done before any pps changes - shares_to_burn: uint256 = 0 # Total fees to charge in shares. total_fees_shares: uint256 = 0 # For Protocol fee assessment. protocol_fee_bps: uint16 = 0 protocol_fees_shares: uint256 = 0 protocol_fee_recipient: address = empty(address) + # `shares_to_burn` is derived from amounts that would reduce the vaults PPS. + # NOTE: this needs to be done before any pps changes + shares_to_burn: 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) - # If we have fees then get the proportional amount of shares. + # If we have fees then get the proportional amount of shares to issue. if total_fees > 0: # Get the total amount shares to issue for the fees. total_fees_shares = shares_to_burn * total_fees / (loss + total_fees) - # Get the config for this vault. + # Get the protocol fee config for this vault. protocol_fee_bps, protocol_fee_recipient = IFactory(FACTORY).protocol_fee_config() # If there is a protocol fee. @@ -1208,21 +1211,23 @@ def _process_report(strategy: address) -> (uint256, uint256): # Get the percent of fees to go to protocol fees. protocol_fees_shares = total_fees_shares * convert(protocol_fee_bps, uint256) / MAX_BPS - # Shares to lock is any amounts that would otherwise increase the vaults PPS. + + # Shares to lock is any amount that would otherwise increase the vaults PPS. shares_to_lock: uint256 = 0 profit_max_unlock_time: uint256 = self.profit_max_unlock_time - # Mint anything we are locking to the vault. + # Get the amount we will lock to avoid a PPS increase. if gain + total_refunds > 0 and profit_max_unlock_time != 0: shares_to_lock = self._convert_to_shares(gain + total_refunds, Rounding.ROUND_DOWN) # The total current supply including locked shares. total_supply: uint256 = self.total_supply - # The total shares the vault currently owns. + # The total shares the vault currently owns. Both locked and unlocked. total_locked_shares: uint256 = self.balance_of[self] - # Get the expected end amount of shares after all accounting. + + # Get the desired end amount of shares after all accounting. ending_supply: uint256 = total_supply + shares_to_lock - shares_to_burn - self._unlocked_shares() - # If we will end with more tokens than we have now. + # If we will end with more shares than we have now. if ending_supply > total_supply: # Issue the difference. self._issue_shares(unsafe_sub(ending_supply, total_supply), self) @@ -1236,7 +1241,7 @@ def _process_report(strategy: address) -> (uint256, uint256): # Adjust the amount to lock for this period. if shares_to_lock > shares_to_burn: # Don't lock fees or losses. - shares_to_lock -= shares_to_burn + shares_to_lock = unsafe_sub(shares_to_lock, shares_to_burn) else: shares_to_burn = 0 @@ -1254,8 +1259,8 @@ def _process_report(strategy: address) -> (uint256, uint256): self.strategies[strategy].current_debt = current_debt self.total_debt += gain - # Strategy is reporting a loss - if loss > 0: + # 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 @@ -1265,8 +1270,9 @@ def _process_report(strategy: address) -> (uint256, uint256): # Accountant fees are (total_fees - protocol_fees). self._issue_shares(total_fees_shares - protocol_fees_shares, accountant) - if protocol_fees_shares > 0: - self._issue_shares(protocol_fees_shares, protocol_fee_recipient) + # If we also have protocol fees. + if protocol_fees_shares > 0: + self._issue_shares(protocol_fees_shares, protocol_fee_recipient) # Update unlocking rate and time to fully unlocked. total_locked_shares = self.balance_of[self] @@ -1304,7 +1310,7 @@ def _process_report(strategy: address) -> (uint256, uint256): gain, loss, current_debt, - total_fees * convert(protocol_fee_bps, uint256) / MAX_BPS, + total_fees * convert(protocol_fee_bps, uint256) / MAX_BPS, # Protocol Fees total_fees, total_refunds ) From 045866a71939684ef80015e9a1df780e5ddb4838 Mon Sep 17 00:00:00 2001 From: Schlagonia Date: Fri, 19 Jan 2024 22:22:23 -0700 Subject: [PATCH 5/5] fix: strategy changes --- contracts/test/mocks/ERC4626/MockTokenizedStrategy.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/test/mocks/ERC4626/MockTokenizedStrategy.sol b/contracts/test/mocks/ERC4626/MockTokenizedStrategy.sol index 37774fa2..a25c390e 100644 --- a/contracts/test/mocks/ERC4626/MockTokenizedStrategy.sol +++ b/contracts/test/mocks/ERC4626/MockTokenizedStrategy.sol @@ -58,7 +58,7 @@ contract MockTokenizedStrategy is TokenizedStrategy { function availableDepositLimit( address ) public view virtual returns (uint256) { - uint256 _totalAssets = strategyStorage().totalIdle; + uint256 _totalAssets = totalAssets(); uint256 _maxDebt = maxDebt; return _maxDebt > _totalAssets ? _maxDebt - _totalAssets : 0; }