diff --git a/contracts/Vault.vy b/contracts/Vault.vy index 15ee160f..7928df98 100644 --- a/contracts/Vault.vy +++ b/contracts/Vault.vy @@ -85,10 +85,13 @@ emergencyShutdown: public(bool) depositLimit: public(uint256) # Limit for totalAssets the Vault can hold debtLimit: public(uint256) # Debt limit for the Vault across all strategies totalDebt: public(uint256) # Amount of tokens that all strategies have borrowed +lastReport: public(uint256) # Number of blocks since last report -rewards: public(address) -performanceFee: public(uint256) # Fee for governance rewards -FEE_MAX: constant(uint256) = 10000 # 100%, or 10000 basis points +rewards: public(address) # Rewards contract where Governance fees are sent to +managementFee: public(uint256) # Governance Fee for management of Vault (given to `rewards`) +performanceFee: public(uint256) # Governance Fee for performance of Vault (given to `rewards`) +FEE_MAX: constant(uint256) = 10_000 # 100%, or 10k basis points +BLOCKS_PER_YEAR: constant(uint256) = 2_300_000 @external def __init__( @@ -113,7 +116,9 @@ def __init__( self.rewards = _rewards self.guardian = msg.sender self.performanceFee = 450 # 4.5% of yield (per strategy) + self.managementFee = 200 # 2% per year self.depositLimit = MAX_UINT256 # Start unlimited + self.lastReport = block.number @pure @@ -153,6 +158,12 @@ def setPerformanceFee(_fee: uint256): self.performanceFee = _fee +@external +def setManagementFee(_fee: uint256): + assert msg.sender == self.governance + self.managementFee = _fee + + @external def setGuardian(_guardian: address): assert msg.sender in [self.guardian, self.governance] @@ -388,7 +399,6 @@ def withdraw(_shares: uint256): # We need to go get some from our strategies in the withdrawal queue # NOTE: This performs forced withdrawals from each strategy. There is # a 0.5% withdrawal fee assessed on each forced withdrawal (<= 0.5% total) - totalFee: uint256 = 0 for strategy in self.withdrawalQueue: if strategy == ZERO_ADDRESS: break # We've exhausted the queue @@ -416,13 +426,6 @@ def withdraw(_shares: uint256): self.strategies[strategy].totalDebt -= withdrawn self.totalDebt -= withdrawn - # send withdrawal fee directly to strategist - fee: uint256 = 50 * withdrawn / FEE_MAX - totalFee += fee - self.token.transfer(Strategy(strategy).strategist(), fee) - - value -= totalFee # fee is assessed here, sum(fee) above - # NOTE: We have withdrawn everything possible out of the withdrawal queue # but we still don't have enough to fully pay them back, so adjust # to the total amount we've freed up through forced withdrawals @@ -697,29 +700,34 @@ def report(_return: uint256) -> uint256: debt: uint256 = self._debtOutstanding(msg.sender) # Issue new shares to cover fees - # NOTE: Applies if strategy is not shutting down, or it is but all debt paid off # NOTE: In effect, this reduces overall share price by the combined fee + governance_fee: uint256 = ( + self._totalAssets() * (block.number - self.lastReport) * self.managementFee + ) / FEE_MAX / BLOCKS_PER_YEAR + self.lastReport = block.number + strategist_fee: uint256 = 0 # Only applies in certain conditions + + # NOTE: Applies if strategy is not shutting down, or it is but all debt paid off # NOTE: No fee is taken when a strategy is unwinding it's position, until all debt is paid if _return > debt: - strategist_fee: uint256 = ( + strategist_fee = ( (_return - debt) * self.strategies[msg.sender].performanceFee ) / FEE_MAX - governance_fee: uint256 = ((_return - debt) * self.performanceFee) / FEE_MAX - total_fee: uint256 = governance_fee + strategist_fee - - # NOTE: In certain cases, the calculated fee might be too small - if total_fee > 0: - # NOTE: This must be called prior to taking new collateral, - # or the calculation will be wrong! - # NOTE: This must be done at the same time, to ensure the relative - # ratio of governance_fee : strategist_fee is kept intact - reward: uint256 = self._issueSharesForAmount(self, total_fee) - - # Send the rewards out as new shares in this Vault - strategist_reward: uint256 = (strategist_fee * reward) / total_fee - self._transfer(self, Strategy(msg.sender).strategist(), strategist_reward) - # NOTE: Governance earns the dust - self._transfer(self, self.rewards, self.balanceOf[self]) + governance_fee += (_return - debt) * self.performanceFee / FEE_MAX + + # NOTE: This must be called prior to taking new collateral, + # or the calculation will be wrong! + # NOTE: This must be done at the same time, to ensure the relative + # ratio of governance_fee : strategist_fee is kept intact + total_fee: uint256 = governance_fee + strategist_fee + reward: uint256 = self._issueSharesForAmount(self, total_fee) + + # Send the rewards out as new shares in this Vault + if strategist_fee > 0: + strategist_reward: uint256 = (strategist_fee * reward) / total_fee + self._transfer(self, Strategy(msg.sender).strategist(), strategist_reward) + # NOTE: Governance earns any dust leftover from flooring math above + self._transfer(self, self.rewards, self.balanceOf[self]) # Compute the line of credit the Vault is able to offer the Strategy (if any) credit: uint256 = self._creditAvailable(msg.sender) diff --git a/tests/functional/vault/test_config.py b/tests/functional/vault/test_config.py index 8ea8c89b..8dc0f06a 100644 --- a/tests/functional/vault/test_config.py +++ b/tests/functional/vault/test_config.py @@ -49,6 +49,7 @@ def test_vault_deployment_with_overrides(guardian, gov, rewards, token, Vault): ("guardian", "setGuardian", None), ("rewards", "setRewards", None), ("performanceFee", "setPerformanceFee", 1000), + ("managementFee", "setManagementFee", 1000), ("depositLimit", "setDepositLimit", 1000), ("guardian", "setGuardian", None), ], diff --git a/tests/functional/vault/test_misc.py b/tests/functional/vault/test_misc.py index 5e12794c..9314be6f 100644 --- a/tests/functional/vault/test_misc.py +++ b/tests/functional/vault/test_misc.py @@ -36,6 +36,7 @@ def test_reject_ether(gov, vault): ("setRewards", ["0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"]), ("setGuardian", ["0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"]), ("setPerformanceFee", [0]), + ("setManagementFee", [0]), ("setEmergencyShutdown", [True]), ("approve", ["0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", 1]), ("transfer", ["0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", 1]), diff --git a/tests/functional/vault/test_withdrawal.py b/tests/functional/vault/test_withdrawal.py index 09ba4b40..f1dcccb3 100644 --- a/tests/functional/vault/test_withdrawal.py +++ b/tests/functional/vault/test_withdrawal.py @@ -1,36 +1,59 @@ import brownie -def test_multiple_withdrawals(token, gov, vault, TestStrategy): +def test_multiple_withdrawals(token, gov, Vault, TestStrategy): + # Need a fresh vault to do this math right + vault = Vault.deploy(token, gov, gov, "", "", {"from": gov}) + starting_balance = token.balanceOf(gov) strategies = [gov.deploy(TestStrategy, vault) for _ in range(5)] - [vault.addStrategy(s, 1000, 10, 50, {"from": gov}) for s in strategies] - - before_balance = token.balanceOf(gov) + [ + vault.addStrategy( + s, + token.balanceOf(gov) // 10, # 10% of all tokens + 2 ** 256 - 1, # No rate limit + 0, # No fee + {"from": gov}, + ) + for s in strategies + ] token.approve(vault, 2 ** 256 - 1, {"from": gov}) vault.deposit(token.balanceOf(gov), {"from": gov}) - assert token.balanceOf(gov) < before_balance - before_balance = token.balanceOf(gov) + assert token.balanceOf(gov) == 0 + assert token.balanceOf(vault) == starting_balance [s.harvest({"from": gov}) for s in strategies] # Seed all the strategies with debt + assert token.balanceOf(vault) == starting_balance // 2 for s in strategies: # All of them have debt - print(s, vault.balanceSheetOfStrategy(s)) - assert vault.balanceSheetOfStrategy(s) > 0 - assert vault.balanceSheetOfStrategy(s) == token.balanceOf(s) - - # We withdraw from all the strategies - vault.withdraw(vault.balanceOf(gov) // 2, {"from": gov}) - assert token.balanceOf(gov) > before_balance - before_balance = token.balanceOf(gov) - - assert vault.balanceOf(gov) > 0 + assert ( + vault.balanceSheetOfStrategy(s) + == token.balanceOf(s) + == starting_balance // 10 + ) + + # Withdraw only from Vault + before = token.balanceOf(vault) + vault.withdraw(vault.balanceOf(gov) // 2 + 1, {"from": gov}) + assert token.balanceOf(vault) == 0 + assert token.balanceOf(gov) == before + for s in strategies: + assert ( + vault.balanceSheetOfStrategy(s) + == token.balanceOf(s) + == starting_balance // 10 + ) + + # We've drained all the debt vault.withdraw(vault.balanceOf(gov), {"from": gov}) - assert token.balanceOf(gov) > before_balance - - for s in strategies: # Should have pulled everything from each strategy + for s in strategies: assert vault.balanceSheetOfStrategy(s) == 0 + assert token.balanceOf(s) == 0 + + assert vault.totalDebt() == 0 + for s in strategies: + assert vault.balanceSheetOfStrategy(s) == token.balanceOf(s) == 0 def test_forced_withdrawal(token, gov, vault, TestStrategy, rando, chain): diff --git a/tests/integration/test_operation.py b/tests/integration/test_operation.py index b93d62fe..b7f9af78 100644 --- a/tests/integration/test_operation.py +++ b/tests/integration/test_operation.py @@ -1,5 +1,6 @@ class NormalOperation: - def __init__(self, token, vault, strategy, user, farm, keeper): + def __init__(self, web3, token, vault, strategy, user, farm, keeper): + self.web3 = web3 self.token = token self.vault = vault self.strategy = strategy @@ -7,42 +8,37 @@ def __init__(self, token, vault, strategy, user, farm, keeper): self.keeper = keeper self.user = user - def setup(self): - self.last_price = 1.0 - def rule_deposit(self): - print(" NormalOperation.deposit()") + print(" Vault.deposit()") + # Deposit 50% of what they have left - self.vault.deposit(self.token.balanceOf(self.user) // 2, {"from": self.user}) + amt = self.token.balanceOf(self.user) // 2 + self.vault.deposit(amt, {"from": self.user}) def rule_withdraw(self): - print(" NormalOperation.withdraw()") + print(" Vault.withdraw()") + # Withdraw 50% of what they have in the Vault - self.vault.withdraw(self.vault.balanceOf(self.user) // 2, {"from": self.user}) + amt = self.vault.balanceOf(self.user) // 2 + self.vault.withdraw(amt, {"from": self.user}) + + def rule_harvest(self): + print(" Strategy.harvest()") - def rule_yield(self): - print(" NormalOperation.yield()") # Earn 1% yield on deposits in some farming protocol - self.token.transfer( - self.strategy, - self.token.balanceOf(self.strategy) // 100, - {"from": self.farm}, - ) + amt = self.token.balanceOf(self.strategy) // 100 + self.token.transfer(self.strategy, amt, {"from": self.farm}) - def rule_harvest(self): - print(" NormalOperation.harvest()") # Keeper decides to harvest the yield self.strategy.harvest({"from": self.keeper}) - def invariant_numbergoup(self): - # Positive-return Strategy should never reduce the price of a share - price = self.vault.pricePerShare() / 10 ** self.vault.decimals() - assert price >= self.last_price - self.last_price = price + # TODO: Invariant that user did not get > they should have + # TODO: Invariant that fees/accounting is all perfect + # TODO: Invariant that all economic assumptions are maintained def test_normal_operation( - gov, strategy, vault, token, chad, andre, keeper, state_machine + web3, gov, strategy, vault, token, chad, andre, keeper, state_machine ): vault.addStrategy( strategy, @@ -53,4 +49,4 @@ def test_normal_operation( ) strategy.harvest({"from": keeper}) assert token.balanceOf(vault) == 0 - state_machine(NormalOperation, token, vault, strategy, chad, andre, keeper) + state_machine(NormalOperation, web3, token, vault, strategy, chad, andre, keeper)