From e70b08f420afc41ca89b9a67de9ba7ef45be82ba Mon Sep 17 00:00:00 2001 From: wavey0x Date: Tue, 31 Jan 2023 20:53:30 -0500 Subject: [PATCH 01/10] feat: add loss checker feat: add loss checker feat: add loss checker --- .gitignore | 1 + brownie-config.yml | 4 +- contracts/LossOnFeeChecker.sol | 87 +++++++ contracts/RouterStrategy.sol | 76 ++---- contracts/Synthetix.sol | 177 ------------- contracts/SynthetixRouterStrategy.sol | 341 -------------------------- tests/conftest.py | 35 +-- tests/test_operation_steth.py | 53 ++++ 8 files changed, 189 insertions(+), 585 deletions(-) create mode 100644 contracts/LossOnFeeChecker.sol delete mode 100644 contracts/Synthetix.sol delete mode 100644 contracts/SynthetixRouterStrategy.sol create mode 100644 tests/test_operation_steth.py diff --git a/.gitignore b/.gitignore index 0ff2835..3e9ec3d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ __pycache__ build/ reports/ .env +env # Node/npm node_modules/ diff --git a/brownie-config.yml b/brownie-config.yml index d386fdb..00ac495 100644 --- a/brownie-config.yml +++ b/brownie-config.yml @@ -8,7 +8,7 @@ autofetch_sources: True # require OpenZepplin Contracts dependencies: - - yearn/yearn-vaults@0.4.3 + - yearn/yearn-vaults@0.3.0-2 - OpenZeppelin/openzeppelin-contracts@3.1.0 # path remapping to support imports from GitHub/NPM @@ -16,7 +16,7 @@ compiler: solc: version: 0.6.12 remappings: - - "@yearnvaults=yearn/yearn-vaults@0.4.3" + - "@yearnvaults=yearn/yearn-vaults@0.3.0-2" - "@openzeppelin=OpenZeppelin/openzeppelin-contracts@3.1.0" reports: diff --git a/contracts/LossOnFeeChecker.sol b/contracts/LossOnFeeChecker.sol new file mode 100644 index 0000000..42b0a92 --- /dev/null +++ b/contracts/LossOnFeeChecker.sol @@ -0,0 +1,87 @@ +pragma solidity 0.6.12; +pragma experimental ABIEncoderV2; + +interface IVault { + struct StrategyParams { + uint performanceFee; + uint activation; + uint debtRatio; + uint rateLimit; + uint lastReport; + uint totalDebt; + uint totalGain; + uint totalLoss; + } + function totalAssets() external view returns (uint); + function managementFee() external view returns (uint); + function performanceFee() external view returns (uint); + function strategies(address) external view returns (StrategyParams memory); + function lastReport() external view returns (uint); +} + +interface IStrategy { + function vault() external view returns (address); +} + +/// @title LossOnFeeChecker +/// @notice Designed to prevent Management fees from creating lossy reports on Yearn vaults with API < 0.3.5 +/// @dev Begining with vaults API v0.3.5 management fees are adjust dynamically on report to prevent loss +contract LossOnFeeChecker { + + uint constant MAX_BPS = 10_000; + uint constant SECS_PER_YEAR = 31_557_600; + + /// @notice Check if harvest does not contain a loss after fees + /// @dev should be called automically report transaction + function checkLoss(uint gain, uint loss) external view returns (uint) { + return _check(msg.sender, gain, loss); + } + + /// @notice For testing amounts and strategies not directly on chain + function checkLoss(address strategy, uint gain, uint loss) external view returns (uint) { + return _check(strategy, gain, loss); + } + + function _check(address _strategy, uint gain, uint loss) internal view returns (uint) { + IStrategy strategy = IStrategy(_strategy); + IVault vault = IVault(strategy.vault()); + + uint managementFee = vault.managementFee(); + if (managementFee == 0) return 0; + + uint totalAssets = vault.totalAssets(); + if (totalAssets == 0) return 0; + + uint lastReport = vault.lastReport(); + if (lastReport == 0) return 0; + + IVault.StrategyParams memory params = vault.strategies(msg.sender); + + uint timeSince = block.timestamp - lastReport; + if (timeSince == 0) return 0; + + uint governanceFee = ( + totalAssets * timeSince * managementFee + / MAX_BPS + / SECS_PER_YEAR + ); + + if (gain > 0) { + // These fees are applied only upon a profit + uint strategistFeeAmount = gain * params.performanceFee / MAX_BPS; + uint performanceFeeAmount = gain * vault.performanceFee() / MAX_BPS; + governanceFee = governanceFee + strategistFeeAmount + performanceFeeAmount; + } + + if (gain >= loss){ + uint grossProfit = gain - loss; + if (grossProfit >= governanceFee) return 0; + return governanceFee - grossProfit; + } + else{ + uint grossLoss = loss - gain; + return governanceFee + grossLoss; + } + } +} + diff --git a/contracts/RouterStrategy.sol b/contracts/RouterStrategy.sol index bcbe7ce..0a5b7c2 100644 --- a/contracts/RouterStrategy.sol +++ b/contracts/RouterStrategy.sol @@ -12,15 +12,15 @@ import { } from "@openzeppelin/contracts/token/ERC20/SafeERC20.sol"; import "@openzeppelin/contracts/math/Math.sol"; +interface ILossChecker { + function checkLoss(uint, uint) external view returns (uint); +} + interface IVault is IERC20 { function token() external view returns (address); - function decimals() external view returns (uint256); - function deposit() external; - function pricePerShare() external view returns (uint256); - function withdraw( uint256 amount, address account, @@ -35,15 +35,18 @@ contract RouterStrategy is BaseStrategy { string internal strategyName; IVault public yVault; + ILossChecker public lossChecker; + uint256 public feeLossTolerance; uint256 public maxLoss; bool internal isOriginal = true; constructor( address _vault, address _yVault, + address _lossChecker, string memory _strategyName ) public BaseStrategy(_vault) { - _initializeThis(_yVault, _strategyName); + _initializeThis(_yVault, _strategyName, _lossChecker); } event Cloned(address indexed clone); @@ -54,6 +57,7 @@ contract RouterStrategy is BaseStrategy { address _rewards, address _keeper, address _yVault, + address _lossChecker, string memory _strategyName ) external virtual returns (address newStrategy) { require(isOriginal); @@ -80,6 +84,7 @@ contract RouterStrategy is BaseStrategy { _rewards, _keeper, _yVault, + _lossChecker, _strategyName ); @@ -92,18 +97,21 @@ contract RouterStrategy is BaseStrategy { address _rewards, address _keeper, address _yVault, + address _lossChecker, string memory _strategyName ) public { _initialize(_vault, _strategist, _rewards, _keeper); require(address(yVault) == address(0)); - _initializeThis(_yVault, _strategyName); + _initializeThis(_yVault, _strategyName, _lossChecker); } - function _initializeThis(address _yVault, string memory _strategyName) + function _initializeThis(address _yVault, string memory _strategyName, address _lossChecker) internal { yVault = IVault(_yVault); strategyName = _strategyName; + lossChecker = ILossChecker(_lossChecker); + IERC20(address(want)).approve(_yVault, uint256(-1)); } function name() external view override returns (string memory) { @@ -120,10 +128,6 @@ contract RouterStrategy is BaseStrategy { return balanceOfWant().add(valueOfInvestment()); } - function delegatedAssets() external view override returns (uint256) { - return vault.strategies(address(this)).totalDebt; - } - function prepareReturn(uint256 _debtOutstanding) internal virtual @@ -138,7 +142,7 @@ contract RouterStrategy is BaseStrategy { uint256 _totalAsset = estimatedTotalAssets(); // Estimate the profit we have so far - if (_totalDebt <= _totalAsset) { + if (_totalDebt < _totalAsset) { _profit = _totalAsset.sub(_totalDebt); } @@ -164,6 +168,8 @@ contract RouterStrategy is BaseStrategy { _profit = _profit.sub(_loss); _loss = 0; } + + require(lossChecker.checkLoss(_profit, _loss) <= feeLossTolerance, "TooLossy!"); } function adjustPosition(uint256 _debtOutstanding) @@ -177,7 +183,6 @@ contract RouterStrategy is BaseStrategy { uint256 balance = balanceOfWant(); if (balance > 0) { - _checkAllowance(address(yVault), address(want), balance); yVault.deposit(); } } @@ -221,20 +226,6 @@ contract RouterStrategy is BaseStrategy { yVault.withdraw(sharesToWithdraw, address(this), maxLoss); } - function liquidateAllPositions() - internal - virtual - override - returns (uint256 _amountFreed) - { - return - yVault.withdraw( - yVault.balanceOf(address(this)), - address(this), - maxLoss - ); - } - function prepareMigration(address _newStrategy) internal virtual override { IERC20(yVault).safeTransfer( _newStrategy, @@ -252,29 +243,12 @@ contract RouterStrategy is BaseStrategy { ret[0] = address(yVault); } - function ethToWant(uint256 _amtInWei) - public - view - virtual - override - returns (uint256) - { - return _amtInWei; - } - - function setMaxLoss(uint256 _maxLoss) public onlyVaultManagers { + function setMaxLoss(uint256 _maxLoss) public onlyAuthorized { maxLoss = _maxLoss; } - function _checkAllowance( - address _contract, - address _token, - uint256 _amount - ) internal { - if (IERC20(_token).allowance(address(this), _contract) < _amount) { - IERC20(_token).safeApprove(_contract, 0); - IERC20(_token).safeApprove(_contract, type(uint256).max); - } + function setFeeLossTolerance(uint256 _tolerance) public onlyAuthorized { + feeLossTolerance = _tolerance; } function balanceOfWant() public view returns (uint256) { @@ -291,8 +265,10 @@ contract RouterStrategy is BaseStrategy { function valueOfInvestment() public view virtual returns (uint256) { return - yVault.balanceOf(address(this)).mul(yVault.pricePerShare()).div( - 10**yVault.decimals() - ); + yVault.balanceOf(address(this)) + .mul(yVault.pricePerShare()) + .div( + 10**yVault.decimals() + ); } } diff --git a/contracts/Synthetix.sol b/contracts/Synthetix.sol deleted file mode 100644 index e8467d0..0000000 --- a/contracts/Synthetix.sol +++ /dev/null @@ -1,177 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0 -pragma solidity 0.6.12; -pragma experimental ABIEncoderV2; - -import "@openzeppelin/contracts/math/SafeMath.sol"; -import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; - -import "./Interfaces/synthetix/ISynth.sol"; -import "./Interfaces/synthetix/IReadProxy.sol"; -import "./Interfaces/synthetix/ISynthetix.sol"; -import "./Interfaces/synthetix/IExchanger.sol"; -import "./Interfaces/synthetix/IVirtualSynth.sol"; -import "./Interfaces/synthetix/IExchangeRates.sol"; -import "./Interfaces/synthetix/IAddressResolver.sol"; - -contract Synthetix { - using SafeMath for uint256; - - // ========== SYNTHETIX CONFIGURATION ========== - bytes32 public constant sUSD = "sUSD"; - bytes32 public synthCurrencyKey; - - bytes32 internal constant TRACKING_CODE = "YEARN"; - - // ========== ADDRESS RESOLVER CONFIGURATION ========== - bytes32 private constant CONTRACT_SYNTHETIX = "Synthetix"; - bytes32 private constant CONTRACT_EXCHANGER = "Exchanger"; - bytes32 private constant CONTRACT_EXCHANGERATES = "ExchangeRates"; - bytes32 private constant CONTRACT_SYNTHSUSD = "ProxyERC20sUSD"; - bytes32 private contractSynth; - - IReadProxy public constant readProxy = - IReadProxy(0x4E3b31eB0E5CB73641EE1E65E7dCEFe520bA3ef2); - - function _initializeSynthetix(bytes32 _synth) internal { - // sETH / sBTC / sEUR / sLINK - require(contractSynth == 0, "Synth already assigned."); - contractSynth = _synth; - synthCurrencyKey = ISynth( - IReadProxy(address(resolver().getAddress(_synth))).target() - ) - .currencyKey(); - - require(synthCurrencyKey != 0x0, "!key"); - } - - function _balanceOfSynth() internal view returns (uint256) { - return IERC20(address(_synthCoin())).balanceOf(address(this)); - } - - function _balanceOfSUSD() internal view returns (uint256) { - return IERC20(address(_synthsUSD())).balanceOf(address(this)); - } - - function _synthToSUSD(uint256 _amountToSend) - internal - view - returns (uint256 amountReceived) - { - if (_amountToSend == 0 || _amountToSend == type(uint256).max) { - return _amountToSend; - } - (amountReceived, , ) = _exchanger().getAmountsForExchange( - _amountToSend, - synthCurrencyKey, - sUSD - ); - } - - function _sUSDToSynth(uint256 _amountToSend) - internal - view - returns (uint256 amountReceived) - { - if (_amountToSend == 0 || _amountToSend == type(uint256).max) { - return _amountToSend; - } - (amountReceived, , ) = _exchanger().getAmountsForExchange( - _amountToSend, - sUSD, - synthCurrencyKey - ); - } - - function _sUSDFromSynth(uint256 _amountToReceive) - internal - view - returns (uint256 amountToSend) - { - if (_amountToReceive == 0 || _amountToReceive == type(uint256).max) { - return _amountToReceive; - } - // NOTE: the fee of the trade that would be done (sUSD => synth) in this case - uint256 feeRate = - _exchanger().feeRateForExchange(sUSD, synthCurrencyKey); // in base 1e18 - // formula => amountToReceive (Synth) * price (sUSD/Synth) / (1 - feeRate) - return - _exchangeRates() - .effectiveValue(synthCurrencyKey, _amountToReceive, sUSD) - .mul(1e18) - .div(uint256(1e18).sub(feeRate)); - } - - function _synthFromSUSD(uint256 _amountToReceive) - internal - view - returns (uint256 amountToSend) - { - if (_amountToReceive == 0 || _amountToReceive == type(uint256).max) { - return _amountToReceive; - } - // NOTE: the fee of the trade that would be done (synth => sUSD) in this case - uint256 feeRate = - _exchanger().feeRateForExchange(synthCurrencyKey, sUSD); // in base 1e18 - // formula => amountToReceive (sUSD) * price (Synth/sUSD) / (1 - feeRate) - return - _exchangeRates() - .effectiveValue(sUSD, _amountToReceive, synthCurrencyKey) - .mul(1e18) - .div(uint256(1e18).sub(feeRate)); - } - - function exchangeSynthToSUSD(uint256 amount) internal returns (uint256) { - if (amount == 0) { - return 0; - } - - return - _synthetix().exchangeWithTracking( - synthCurrencyKey, - amount, - sUSD, - address(this), - TRACKING_CODE - ); - } - - function exchangeSUSDToSynth(uint256 amount) internal returns (uint256) { - // swap amount of sUSD for Synth - if (amount == 0) { - return 0; - } - - return - _synthetix().exchangeWithTracking( - sUSD, - amount, - synthCurrencyKey, - address(this), - TRACKING_CODE - ); - } - - function resolver() internal view returns (IAddressResolver) { - return IAddressResolver(readProxy.target()); - } - - function _synthCoin() internal view returns (ISynth) { - return ISynth(resolver().getAddress(contractSynth)); - } - - function _synthsUSD() internal view returns (ISynth) { - return ISynth(resolver().getAddress(CONTRACT_SYNTHSUSD)); - } - - function _synthetix() internal view returns (ISynthetix) { - return ISynthetix(resolver().getAddress(CONTRACT_SYNTHETIX)); - } - - function _exchangeRates() internal view returns (IExchangeRates) { - return IExchangeRates(resolver().getAddress(CONTRACT_EXCHANGERATES)); - } - - function _exchanger() internal view returns (IExchanger) { - return IExchanger(resolver().getAddress(CONTRACT_EXCHANGER)); - } -} diff --git a/contracts/SynthetixRouterStrategy.sol b/contracts/SynthetixRouterStrategy.sol deleted file mode 100644 index 6fa5162..0000000 --- a/contracts/SynthetixRouterStrategy.sol +++ /dev/null @@ -1,341 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0 -pragma solidity 0.6.12; -pragma experimental ABIEncoderV2; - -import "./RouterStrategy.sol"; -import "./Synthetix.sol"; - -interface IUni { - function getAmountsOut(uint256 amountIn, address[] calldata path) - external - view - returns (uint256[] memory amounts); -} - -contract SynthetixRouterStrategy is RouterStrategy, Synthetix { - uint256 internal constant DENOMINATOR = 10_000; - uint256 internal constant DUST_THRESHOLD = 10_000; - address public constant weth = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; - address public constant uniswapRouter = - 0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D; - - // This is the amount of sUSD that should not be exchanged for synth - // Usually 100 for 1%. - uint256 public susdBuffer; - - constructor( - address _vault, - address _yVault, - string memory _strategyName, - bytes32 _synth, - uint256 _susdBuffer - ) public RouterStrategy(_vault, _yVault, _strategyName) { - _initializeSynthetixRouter(_synth, _susdBuffer); - } - - function cloneRouter( - address _vault, - address _strategist, - address _rewards, - address _keeper, - address _yVault, - string memory _strategyName - ) external override returns (address newStrategy) { - revert(); - } - - function cloneSynthetixRouter( - address _vault, - address _strategist, - address _rewards, - address _keeper, - address _yVault, - string memory _strategyName, - bytes32 _synth, - uint256 _susdBuffer - ) external returns (address newStrategy) { - require(isOriginal); - // Copied from https://github.com/optionality/clone-factory/blob/master/contracts/CloneFactory.sol - bytes20 addressBytes = bytes20(address(this)); - assembly { - // EIP-1167 bytecode - let clone_code := mload(0x40) - mstore( - clone_code, - 0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000000000000000000000 - ) - mstore(add(clone_code, 0x14), addressBytes) - mstore( - add(clone_code, 0x28), - 0x5af43d82803e903d91602b57fd5bf30000000000000000000000000000000000 - ) - newStrategy := create(0, clone_code, 0x37) - } - - SynthetixRouterStrategy(newStrategy).initialize( - _vault, - _strategist, - _rewards, - _keeper, - _yVault, - _strategyName, - _synth, - _susdBuffer - ); - - emit Cloned(newStrategy); - } - - function initialize( - address _vault, - address _strategist, - address _rewards, - address _keeper, - address _yVault, - string memory _strategyName, - bytes32 _synth, - uint256 _susdBuffer - ) public { - super.initialize( - _vault, - _strategist, - _rewards, - _keeper, - _yVault, - _strategyName - ); - _initializeSynthetixRouter(_synth, _susdBuffer); - } - - function _initializeSynthetixRouter(bytes32 _synth, uint256 _susdBuffer) - internal - { - _initializeSynthetix(_synth); - susdBuffer = _susdBuffer; - } - - function adjustPosition(uint256 _debtOutstanding) internal override { - if (emergencyExit) { - return; - } - uint256 looseSynth = _balanceOfSynth(); - uint256 _sUSDBalance = balanceOfWant(); - - // this will tell us how much we need to keep in the buffer - uint256 totalDebt = vault.strategies(address(this)).totalDebt; // in sUSD (want) - uint256 buffer = totalDebt.mul(susdBuffer).div(DENOMINATOR); - - uint256 _sUSDToInvest = - _sUSDBalance > buffer ? _sUSDBalance.sub(buffer) : 0; - uint256 _sUSDNeeded = _sUSDToInvest == 0 ? buffer.sub(_sUSDBalance) : 0; - uint256 _synthToSell = - _sUSDNeeded > 0 ? _synthFromSUSD(_sUSDNeeded) : 0; // amount of Synth that we need to sell to refill buffer - - if (_synthToSell == 0) { - // This will first deposit any loose synth in the vault if it not locked - // Then will invest all available sUSD (exchanging to Synth) - // After this, user has to manually call depositInVault to deposit synth after settlement period - if (_sUSDToInvest == 0) { - return; - } - if (isWaitingPeriodFinished() && looseSynth > DUST_THRESHOLD) { - depositInVault(); - } - exchangeSUSDToSynth(_sUSDToInvest); - // now the waiting period starts - } else if (_synthToSell >= DUST_THRESHOLD) { - // this means that we need to refill the buffer - // we may have already some uninvested Synth so we use it - uint256 available = _synthToSUSD(looseSynth); - uint256 sUSDToWithdraw = - _sUSDNeeded > available ? _sUSDNeeded.sub(available) : 0; - // this will withdraw and sell full balance of Synth (inside withdrawSomeWant) - if (sUSDToWithdraw > 0) { - withdrawSomeWant(sUSDToWithdraw, true); - } - } - } - - function liquidatePosition(uint256 _amountNeeded) - internal - override - returns (uint256 _liquidatedAmount, uint256 _loss) - { - uint256 wantBal = balanceOfWant(); // want is always sUSD - - if (wantBal < _amountNeeded) { - (_liquidatedAmount, _loss) = withdrawSomeWant(_amountNeeded, false); - } - - _liquidatedAmount = Math.min( - _amountNeeded, - _liquidatedAmount == 0 ? wantBal : _liquidatedAmount - ); - } - - function updateSUSDBuffer(uint256 _susdBuffer) public onlyVaultManagers { - require(_susdBuffer <= 10_000, "!too high"); - susdBuffer = _susdBuffer; - } - - function isWaitingPeriodFinished() public view returns (bool freeToMove) { - return - _exchanger().maxSecsLeftInWaitingPeriod( - address(this), - synthCurrencyKey - ) == 0; - } - - function depositInVault() public onlyVaultManagers { - uint256 balanceOfSynth = _balanceOfSynth(); - if (balanceOfSynth > DUST_THRESHOLD && isWaitingPeriodFinished()) { - _checkAllowance( - address(yVault), - address(_synthCoin()), - balanceOfSynth - ); - yVault.deposit(); - } - } - - //safe to enter more than we have - function withdrawSomeWant(uint256 _amount, bool performExchanges) - private - returns (uint256 _liquidatedAmount, uint256 _loss) - { - if (performExchanges) { - // we exchange synths to susd - uint256 synthBalanceBefore = _balanceOfSynth(); - uint256 sUSDBalanceBefore = balanceOfWant(); - - if (sUSDBalanceBefore < _amount) { - uint256 _newAmount = _amount.sub(sUSDBalanceBefore); - - uint256 _synthAmount = _synthFromSUSD(_newAmount); - if (isWaitingPeriodFinished()) { - if (_synthAmount <= synthBalanceBefore) { - exchangeSynthToSUSD(_synthAmount); - return (_amount, 0); - } - - _synthAmount = _synthAmount.sub(synthBalanceBefore); - } - _withdrawFromYVault(_synthAmount); - uint256 newBalanceOfSynth = _balanceOfSynth(); - if ( - newBalanceOfSynth > DUST_THRESHOLD && - isWaitingPeriodFinished() - ) { - exchangeSynthToSUSD(newBalanceOfSynth); - } - } - } - - uint256 totalAssets = balanceOfWant(); - if (_amount > totalAssets) { - _liquidatedAmount = totalAssets; - _loss = _amount.sub(totalAssets); - } else { - _liquidatedAmount = _amount; - } - } - - function liquidateAllPositions() - internal - override - returns (uint256 _amountFreed) - { - // In order to work, manualRemoveFullLiquidity needs to be call 6 min in advance - require(isWaitingPeriodFinished(), "settlement period"); - require(valueOfInvestment() < DUST_THRESHOLD, "remove liquidity first"); - require( - _balanceOfSynth() < DUST_THRESHOLD, - "exchange synth to want first" - ); - _amountFreed = balanceOfWant(); - } - - function manualRemoveFullLiquidity() - external - onlyVaultManagers - returns (uint256 _liquidatedAmount, uint256 _loss) - { - // It will withdraw all the assets from the yvault and the exchange them to want - (_liquidatedAmount, _loss) = withdrawSomeWant( - estimatedTotalAssets(), - true - ); - } - - function manualRemoveLiquidity(uint256 _liquidityToRemove) - external - onlyVaultManagers - returns (uint256 _liquidatedAmount, uint256 _loss) - { - _liquidityToRemove = Math.min( - _liquidityToRemove, - estimatedTotalAssets() - ); - // It will withdraw _liquidityToRemove assets from the yvault and the exchange them to want - (_liquidatedAmount, _loss) = withdrawSomeWant(_liquidityToRemove, true); - } - - function estimatedTotalAssets() public view override returns (uint256) { - return - balanceOfWant().add(_sUSDFromSynth(_balanceOfSynth())).add( - valueOfInvestment() - ); - } - - function ethToWant(uint256 _amtInWei) - public - view - override - returns (uint256) - { - address[] memory path = new address[](2); - path[0] = weth; - path[1] = address(want); - - uint256[] memory amounts = - IUni(uniswapRouter).getAmountsOut(_amtInWei, path); - - return amounts[amounts.length - 1]; - } - - function valueOfInvestment() public view override returns (uint256) { - return - _sUSDFromSynth( - yVault.balanceOf(address(this)).mul(yVault.pricePerShare()).div( - 10**yVault.decimals() - ) - ); - } - - function prepareMigration(address _newStrategy) internal override { - super.prepareMigration(_newStrategy); - _synthCoin().transferAndSettle(_newStrategy, _balanceOfSynth()); - } - - function prepareReturn(uint256 _debtOutstanding) - internal - override - returns ( - uint256 _profit, - uint256 _loss, - uint256 _debtPayment - ) - { - uint256 totalDebt = vault.strategies(address(this)).totalDebt; - uint256 totalAssetsAfterProfit = estimatedTotalAssets(); - uint256 _balanceOfWant = balanceOfWant(); - - _debtPayment = Math.min(_debtOutstanding, _balanceOfWant); - - if (totalDebt <= totalAssetsAfterProfit) { - _profit = _balanceOfWant.sub(_debtPayment); - } else { - _loss = totalDebt.sub(totalAssetsAfterProfit); - } - } -} diff --git a/tests/conftest.py b/tests/conftest.py index 5fca23c..f0583b6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,5 @@ import pytest -from brownie import config, Contract, ZERO_ADDRESS +from brownie import config, Contract, ZERO_ADDRESS, LossOnFeeChecker from eth_abi import encode_single @@ -52,29 +52,26 @@ def yvweth_032(): def yvweth_042(): yield Contract("0xa258C4606Ca8206D8aA700cE2143D7db854D168c") - @pytest.fixture -def origin_vault(): - # origin vault of the route - yield Contract("0xa9fE4601811213c340e850ea305481afF02f5b28") +def yvsteth_030(gov): + yield Contract("0xdCD90C7f6324cfa40d7169ef80b12031770B4325",owner=gov) @pytest.fixture -def destination_vault(): - # destination vault of the route - yield Contract("0xa258C4606Ca8206D8aA700cE2143D7db854D168c") +def yvsteth_045(gov): + yield Contract("0x5B8C556B8b2a78696F0B9B830B3d67623122E270",owner=gov) @pytest.fixture -def origin_vault(): +def origin_vault(yvsteth_030): # origin vault of the route - yield Contract("0xa9fE4601811213c340e850ea305481afF02f5b28") + yield yvsteth_030 @pytest.fixture -def destination_vault(): +def destination_vault(yvsteth_045): # destination vault of the route - yield Contract("0xa258C4606Ca8206D8aA700cE2143D7db854D168c") + yield yvsteth_045 @pytest.fixture @@ -114,6 +111,9 @@ def weth_amout(user, weth): def health_check(): yield Contract("0xddcea799ff1699e98edf118e0629a974df7df012") +@pytest.fixture +def loss_checker(strategist): + yield strategist.deploy(LossOnFeeChecker) @pytest.fixture def vault(pm, gov, rewards, guardian, management, token): @@ -134,10 +134,11 @@ def strategy( destination_vault, RouterStrategy, gov, + loss_checker, health_check, ): strategy = strategist.deploy( - RouterStrategy, origin_vault, destination_vault, "Route yvWETH 042" + RouterStrategy, origin_vault, destination_vault, loss_checker, "Strat "+origin_vault.symbol() ) strategy.setKeeper(keeper) @@ -147,13 +148,17 @@ def strategy( break origin_vault.updateStrategyDebtRatio(strat_address, 0, {"from": gov}) + try: + Contract(strat_address,owner=gov).setDoHealthCheck(False) + except: + pass + Contract(strat_address,owner=gov).harvest() strategy.setHealthCheck(health_check, {"from": origin_vault.governance()}) - origin_vault.addStrategy(strategy, 10_000, 0, 2 ** 256 - 1, 0, {"from": gov}) + origin_vault.addStrategy(strategy, 10_000, 0, 0, {"from": gov}) yield strategy - @pytest.fixture def unique_strategy( strategist, keeper, yvweth_032, yvweth_042, RouterStrategy, gov, health_check diff --git a/tests/test_operation_steth.py b/tests/test_operation_steth.py new file mode 100644 index 0000000..2e8ff09 --- /dev/null +++ b/tests/test_operation_steth.py @@ -0,0 +1,53 @@ +import pytest +from brownie import Contract, ZERO_ADDRESS, Wei, chain, accounts + + +def test_migrate(origin_vault, destination_vault, strategy, gov, loss_checker): + # hack :) + old_vault = origin_vault + new_vault = destination_vault + old_strategy = Contract(old_vault.withdrawalQueue(0),owner=gov) + new_strategy = strategy + + # At this point, all fund are already removed from existing strats + # We only have to harvest into our router strat, which is already at 100% DR + strategy.setFeeLossTolerance(1e18,{"from": gov}) + strategy.harvest({"from": gov}) + chain.sleep(60 * 60 * 24 * 7) + + # No sells, means no profit yet + # Let's set mgmt fee pretty high + origin_vault.setManagementFee(500, {"from": gov}) + + expectedLoss = loss_checker.checkLoss(strategy, 0, 0) + print(f'EXPECTED LOSS AMOUNT = {expectedLoss}') + expected_loss = emulate_fees(strategy, origin_vault) + + whale = accounts.at('0x99ac10631F69C753DDb595D074422a0922D9056B', force=True) + want = Contract(origin_vault.token(),owner=whale) + # want.transfer(strategy, expected_loss + 1e17) + assert False + + totalShares = origin_vault.totalSupply() + tx = strategy.harvest({"from": gov}) + totalSharesAfter = origin_vault.totalSupply() + assert totalSharesAfter <= totalShares + + print(tx.events['Harvested']) + assert False + # origin_vault.updateStrategyDebtRatio(strategy, 10_000, {'from':gov}) + + + +def emulate_fees(strategy, origin_vault): + SECS_PER_YEAR = 31_557_600 + MAX_BPS = 10_000 + v = origin_vault + mgmt_fee = v.managementFee() + params = v.strategies(strategy).dict() + last = v.lastReport() + current = chain.time() + time_since = current - last + total_assets = v.totalAssets() + gov_fee = total_assets * time_since * mgmt_fee / MAX_BPS / SECS_PER_YEAR + return gov_fee \ No newline at end of file From ffa5fd2dc72c2ca037bb64c6539be98faefa07b5 Mon Sep 17 00:00:00 2001 From: wavey0x Date: Wed, 1 Feb 2023 08:46:14 -0500 Subject: [PATCH 02/10] feat: sweep from loss checker feat: sweep from loss checker --- contracts/LossOnFeeChecker.sol | 19 +++++++++++++++++++ contracts/RouterStrategy.sol | 7 ++++++- tests/test_operation_steth.py | 2 -- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/contracts/LossOnFeeChecker.sol b/contracts/LossOnFeeChecker.sol index 42b0a92..ab07e04 100644 --- a/contracts/LossOnFeeChecker.sol +++ b/contracts/LossOnFeeChecker.sol @@ -1,6 +1,11 @@ pragma solidity 0.6.12; pragma experimental ABIEncoderV2; +import { + SafeERC20, + IERC20 +} from "@openzeppelin/contracts/token/ERC20/SafeERC20.sol"; + interface IVault { struct StrategyParams { uint performanceFee; @@ -27,9 +32,11 @@ interface IStrategy { /// @notice Designed to prevent Management fees from creating lossy reports on Yearn vaults with API < 0.3.5 /// @dev Begining with vaults API v0.3.5 management fees are adjust dynamically on report to prevent loss contract LossOnFeeChecker { + using SafeERC20 for IERC20; uint constant MAX_BPS = 10_000; uint constant SECS_PER_YEAR = 31_557_600; + mapping(address=>bool) public approvedSweepers; /// @notice Check if harvest does not contain a loss after fees /// @dev should be called automically report transaction @@ -83,5 +90,17 @@ contract LossOnFeeChecker { return governanceFee + grossLoss; } } + + function sweep(address _token, uint _amount) external { + require(approvedSweepers[msg.sender], "!approved"); + IERC20(_token).transfer(msg.sender, _amount); + } + + function approveSweepers(address _sweeper, bool _approved) external { + address ychad = 0xFEB4acf3df3cDEA7399794D0869ef76A6EfAff52; + address brain = 0x16388463d60FFE0661Cf7F1f31a7D658aC790ff7; + require(msg.sender == ychad || msg.sender == brain); + approvedSweepers[_sweeper] = _approved; + } } diff --git a/contracts/RouterStrategy.sol b/contracts/RouterStrategy.sol index 0a5b7c2..df97a9c 100644 --- a/contracts/RouterStrategy.sol +++ b/contracts/RouterStrategy.sol @@ -14,6 +14,7 @@ import "@openzeppelin/contracts/math/Math.sol"; interface ILossChecker { function checkLoss(uint, uint) external view returns (uint); + function sweep(address, uint) external; } interface IVault is IERC20 { @@ -169,7 +170,11 @@ contract RouterStrategy is BaseStrategy { _loss = 0; } - require(lossChecker.checkLoss(_profit, _loss) <= feeLossTolerance, "TooLossy!"); + uint expectedLoss = lossChecker.checkLoss(_profit, _loss); + if (expectedLoss > feeLossTolerance){ + require(want.balanceOf(address(lossChecker)) > expectedLoss, "LossyWithFees"); + lossChecker.sweep(address(want), expectedLoss); + } } function adjustPosition(uint256 _debtOutstanding) diff --git a/tests/test_operation_steth.py b/tests/test_operation_steth.py index 2e8ff09..942435b 100644 --- a/tests/test_operation_steth.py +++ b/tests/test_operation_steth.py @@ -26,7 +26,6 @@ def test_migrate(origin_vault, destination_vault, strategy, gov, loss_checker): whale = accounts.at('0x99ac10631F69C753DDb595D074422a0922D9056B', force=True) want = Contract(origin_vault.token(),owner=whale) # want.transfer(strategy, expected_loss + 1e17) - assert False totalShares = origin_vault.totalSupply() tx = strategy.harvest({"from": gov}) @@ -34,7 +33,6 @@ def test_migrate(origin_vault, destination_vault, strategy, gov, loss_checker): assert totalSharesAfter <= totalShares print(tx.events['Harvested']) - assert False # origin_vault.updateStrategyDebtRatio(strategy, 10_000, {'from':gov}) From 096a8ac2f0d557884dcd2addfc39958d04a94842 Mon Sep 17 00:00:00 2001 From: wavey0x Date: Tue, 7 Feb 2023 21:44:17 -0500 Subject: [PATCH 03/10] feat: rewrite in vyper --- contracts/LossOnFeeChecker.sol | 2 +- contracts/LossOnFeeChecker.vy | 185 +++++++++++++++++++++++++++++++++ contracts/RouterStrategy.sol | 16 +-- tests/test_operation_steth.py | 2 +- 4 files changed, 195 insertions(+), 10 deletions(-) create mode 100644 contracts/LossOnFeeChecker.vy diff --git a/contracts/LossOnFeeChecker.sol b/contracts/LossOnFeeChecker.sol index ab07e04..03a670d 100644 --- a/contracts/LossOnFeeChecker.sol +++ b/contracts/LossOnFeeChecker.sol @@ -31,7 +31,7 @@ interface IStrategy { /// @title LossOnFeeChecker /// @notice Designed to prevent Management fees from creating lossy reports on Yearn vaults with API < 0.3.5 /// @dev Begining with vaults API v0.3.5 management fees are adjust dynamically on report to prevent loss -contract LossOnFeeChecker { +contract LossOnFeeCheckerSol { using SafeERC20 for IERC20; uint constant MAX_BPS = 10_000; diff --git a/contracts/LossOnFeeChecker.vy b/contracts/LossOnFeeChecker.vy new file mode 100644 index 0000000..81674fa --- /dev/null +++ b/contracts/LossOnFeeChecker.vy @@ -0,0 +1,185 @@ +# @version 0.3.7 +""" +@title Loss On Fee Checker +@author Yearn Finance +@license MIT +@dev + Blocks harvests on strategies that would experiece a loss due to fees. + Standard healthcheck in unable to catch such losses, thus this special + purpose contract is useful. +""" + +from vyper.interfaces import ERC20 +from vyper.interfaces import ERC20Detailed + + +struct StrategyParams: + performanceFee: uint256 + activation: uint256 + debtRatio: uint256 + rateLimit: uint256 + lastReport: uint256 + totalDebt: uint256 + totalGain: uint256 + totalLoss: uint256 + +interface IVault: + def totalAssets() -> uint256: view + def managementFee() -> uint256: view + def performanceFee() -> uint256: view + def strategies(strategy: address) -> StrategyParams: view + def lastReport() -> uint256: view + def totalDebt() -> uint256: view + def apiVersion() -> String[28]: view + +interface IStrategy: + def vault() -> address: view + def want() -> address: view + +MAX_BPS: constant(uint256) = 10_000 +SECS_PER_YEAR: constant(uint256) = 31_557_600 +approved_sweepers: public(HashMap[address, bool]) + +@external +@view +def checkLoss(gain: uint256, loss: uint256, strategy: address = msg.sender) -> uint256: + return self._check(gain, loss, strategy) + +@internal +@view +def _check(gain: uint256, loss: uint256, strategy: address) -> uint256: + """ + @notice + Check for losses generated by fees and return a precise amount. + @param gain - amount of reported gain + @param loss - amount of reported loss + @param strategy - strategy to compute for. defaults to msg.sender for use on-chain. + @return amount of projected loss + @dev + Fee calculations replicate logic from vault versions + 0.3.0 - mgmt fee calculated based on total assets in vault + 0.3.1 - mgmt calculated based on total debt of vault + 0.3.2 - fee calculations are same as 0.3.1 + """ + + vault: IVault = IVault(IStrategy(strategy).vault()) + + management_fee: uint256 = vault.managementFee() + if management_fee == 0: + return 0 + + total_assets: uint256 = vault.totalAssets() + if total_assets == 0: + return 0 + + last_report: uint256 = vault.lastReport() + if last_report == 0: + return 0 + + time_since: uint256 = block.timestamp - last_report + if time_since == 0: + return 0 + + params: StrategyParams = vault.strategies(strategy) + + api: String[28] = vault.apiVersion() + if api == "0.3.0": + return self._calc030(params, vault, gain, loss, management_fee, total_assets, time_since) + elif api == "0.3.1" or api == "0.3.2": + return self._calc031(params, vault, gain, loss, management_fee, time_since) + else: + raise # @dev: Vault api version not supported + + return 0 + +@internal +@view +def _calc030( + params: StrategyParams, + vault: IVault, + gain: uint256, + loss: uint256, + management_fee: uint256, + total_assets: uint256, + time_since: uint256 +) -> uint256: + governance_fee: uint256 = ( + total_assets * time_since * management_fee + / MAX_BPS + / SECS_PER_YEAR + ) + + if gain > 0: + strategist_fee: uint256 = gain * params.performanceFee / MAX_BPS + performance_fee: uint256 = gain * vault.performanceFee() / MAX_BPS + governance_fee = governance_fee + strategist_fee + performance_fee + + if gain >= loss: + gross_profit: uint256 = gain - loss + if gross_profit >= governance_fee: + return 0 + else: + return governance_fee - gross_profit + else: + gross_loss: uint256 = loss - gain + return gross_loss + governance_fee + + return 0 + +@internal +@view +def _calc031( + params: StrategyParams, + vault: IVault, + gain: uint256, + loss: uint256, + management_fee: uint256, + time_since: uint256 +) -> uint256: + vault_debt: uint256 = vault.totalDebt() + governance_fee: uint256 = ( + vault_debt * time_since * management_fee + / MAX_BPS + / SECS_PER_YEAR + ) + + if gain > 0: + strategist_fee: uint256 = gain * params.performanceFee / MAX_BPS + performance_fee: uint256 = gain * vault.performanceFee() / MAX_BPS + governance_fee = governance_fee + strategist_fee + performance_fee + + if gain >= loss: + gross_profit: uint256 = gain - loss + if gross_profit >= governance_fee: + return 0 + else: + gross_loss: uint256 = loss - gain + return gross_loss + governance_fee + + return 0 + +@external +def sweep(token: address, amount: uint256): + """ + @notice + Allow a strategy to pull a token balance to offset losses. + @dev + Intended to transfer precise amount of want based on losses generated from fees. + Because a loss can increase block by block, this method allows us to atomically + airdrop an amount of want with some buffer, harvest the strategy, then sweep out + the any buffer atomically. + Token balances should be kept at 0 during normal operation. + """ + assert self.approved_sweepers[msg.sender] # @dev: !approved + ERC20(token).transfer(msg.sender, amount, default_return_value=True) + +@external +def approve_sweepers(sweeper: address, approved: bool): + """ + @notice + Approve strategies to sweep + """ + ychad: address = 0xFEB4acf3df3cDEA7399794D0869ef76A6EfAff52 + ybrain: address = 0x16388463d60FFE0661Cf7F1f31a7D658aC790ff7 + assert msg.sender in [ychad, ybrain] # @dev: !approved + self.approved_sweepers[sweeper] = approved \ No newline at end of file diff --git a/contracts/RouterStrategy.sol b/contracts/RouterStrategy.sol index df97a9c..3b76ec3 100644 --- a/contracts/RouterStrategy.sol +++ b/contracts/RouterStrategy.sol @@ -17,11 +17,15 @@ interface ILossChecker { function sweep(address, uint) external; } +interface ISharesHelper { + function sharesToAmount(address, uint) external view returns (uint); + function amountToShares(address, uint) external view returns (uint); +} + interface IVault is IERC20 { function token() external view returns (address); function decimals() external view returns (uint256); function deposit() external; - function pricePerShare() external view returns (uint256); function withdraw( uint256 amount, address account, @@ -40,6 +44,7 @@ contract RouterStrategy is BaseStrategy { uint256 public feeLossTolerance; uint256 public maxLoss; bool internal isOriginal = true; + ISharesHelper public constant sharesHelper = ISharesHelper(0x444443bae5bB8640677A8cdF94CB8879Fec948Ec); constructor( address _vault, @@ -265,15 +270,10 @@ contract RouterStrategy is BaseStrategy { view returns (uint256) { - return amount.mul(10**yVault.decimals()).div(yVault.pricePerShare()); + return sharesHelper.amountToShares(address(yVault), amount); } function valueOfInvestment() public view virtual returns (uint256) { - return - yVault.balanceOf(address(this)) - .mul(yVault.pricePerShare()) - .div( - 10**yVault.decimals() - ); + return sharesHelper.sharesToAmount(address(yVault), yVault.balanceOf(address(this))); } } diff --git a/tests/test_operation_steth.py b/tests/test_operation_steth.py index 942435b..aa6608f 100644 --- a/tests/test_operation_steth.py +++ b/tests/test_operation_steth.py @@ -19,7 +19,7 @@ def test_migrate(origin_vault, destination_vault, strategy, gov, loss_checker): # Let's set mgmt fee pretty high origin_vault.setManagementFee(500, {"from": gov}) - expectedLoss = loss_checker.checkLoss(strategy, 0, 0) + expectedLoss = loss_checker.checkLoss(0, 0, strategy) print(f'EXPECTED LOSS AMOUNT = {expectedLoss}') expected_loss = emulate_fees(strategy, origin_vault) From 9ecec36e1b80c4dfaa3916e7dd91b98cce3fee13 Mon Sep 17 00:00:00 2001 From: wavey0x Date: Wed, 8 Feb 2023 00:43:03 -0500 Subject: [PATCH 04/10] feat: simplify, remove sweep, and handle strategyparams --- contracts/LossOnFeeChecker.vy | 92 ++++++++++++++++++----------------- contracts/RouterStrategy.sol | 12 ++--- tests/conftest.py | 11 +++-- tests/test_operation_steth.py | 32 ++++++++++-- 4 files changed, 87 insertions(+), 60 deletions(-) diff --git a/contracts/LossOnFeeChecker.vy b/contracts/LossOnFeeChecker.vy index 81674fa..1d36323 100644 --- a/contracts/LossOnFeeChecker.vy +++ b/contracts/LossOnFeeChecker.vy @@ -12,17 +12,6 @@ from vyper.interfaces import ERC20 from vyper.interfaces import ERC20Detailed - -struct StrategyParams: - performanceFee: uint256 - activation: uint256 - debtRatio: uint256 - rateLimit: uint256 - lastReport: uint256 - totalDebt: uint256 - totalGain: uint256 - totalLoss: uint256 - interface IVault: def totalAssets() -> uint256: view def managementFee() -> uint256: view @@ -32,17 +21,46 @@ interface IVault: def totalDebt() -> uint256: view def apiVersion() -> String[28]: view +interface IVaultNew: + def strategies(strategy: address) -> StrategyParamsNew: view + interface IStrategy: def vault() -> address: view def want() -> address: view +event Sweep: + sweeper: indexed(address) + token: indexed(address) + amount: uint256 + +struct StrategyParams: + performanceFee: uint256 + activation: uint256 + debtRatio: uint256 + rateLimit: uint256 + lastReport: uint256 + totalDebt: uint256 + totalGain: uint256 + totalLoss: uint256 + +struct StrategyParamsNew: + performanceFee: uint256 + activation: uint256 + debtRatio: uint256 + minDebtPerHarvest: uint256 + maxDebtPerHarvest: uint256 + lastReport: uint256 + totalDebt: uint256 + totalGain: uint256 + totalLoss: uint256 + MAX_BPS: constant(uint256) = 10_000 SECS_PER_YEAR: constant(uint256) = 31_557_600 approved_sweepers: public(HashMap[address, bool]) @external @view -def checkLoss(gain: uint256, loss: uint256, strategy: address = msg.sender) -> uint256: +def check_loss(gain: uint256, loss: uint256, strategy: address = msg.sender) -> uint256: return self._check(gain, loss, strategy) @internal @@ -59,7 +77,7 @@ def _check(gain: uint256, loss: uint256, strategy: address) -> uint256: Fee calculations replicate logic from vault versions 0.3.0 - mgmt fee calculated based on total assets in vault 0.3.1 - mgmt calculated based on total debt of vault - 0.3.2 - fee calculations are same as 0.3.1 + 0.3.2 - fee calculations are same as 0.3.1, but StrategyParams are different """ vault: IVault = IVault(IStrategy(strategy).vault()) @@ -80,13 +98,16 @@ def _check(gain: uint256, loss: uint256, strategy: address) -> uint256: if time_since == 0: return 0 - params: StrategyParams = vault.strategies(strategy) - api: String[28] = vault.apiVersion() if api == "0.3.0": - return self._calc030(params, vault, gain, loss, management_fee, total_assets, time_since) - elif api == "0.3.1" or api == "0.3.2": - return self._calc031(params, vault, gain, loss, management_fee, time_since) + params: StrategyParams = vault.strategies(strategy) + return self._calc030(vault, gain, loss, management_fee, params.performanceFee, total_assets, time_since) + if api == "0.3.1": + params: StrategyParams = vault.strategies(strategy) + return self._calc031(vault, gain, loss, management_fee, params.performanceFee, time_since) + if api == "0.3.2": + params: StrategyParamsNew = IVaultNew(vault.address).strategies(strategy) + return self._calc031(vault, gain, loss, management_fee, params.performanceFee, time_since) else: raise # @dev: Vault api version not supported @@ -95,11 +116,11 @@ def _check(gain: uint256, loss: uint256, strategy: address) -> uint256: @internal @view def _calc030( - params: StrategyParams, vault: IVault, gain: uint256, loss: uint256, management_fee: uint256, + strat_perf_fee: uint256, total_assets: uint256, time_since: uint256 ) -> uint256: @@ -110,7 +131,7 @@ def _calc030( ) if gain > 0: - strategist_fee: uint256 = gain * params.performanceFee / MAX_BPS + strategist_fee: uint256 = gain * strat_perf_fee / MAX_BPS performance_fee: uint256 = gain * vault.performanceFee() / MAX_BPS governance_fee = governance_fee + strategist_fee + performance_fee @@ -129,11 +150,11 @@ def _calc030( @internal @view def _calc031( - params: StrategyParams, vault: IVault, gain: uint256, loss: uint256, management_fee: uint256, + strat_perf_fee: uint256, time_since: uint256 ) -> uint256: vault_debt: uint256 = vault.totalDebt() @@ -144,7 +165,7 @@ def _calc031( ) if gain > 0: - strategist_fee: uint256 = gain * params.performanceFee / MAX_BPS + strategist_fee: uint256 = gain * strat_perf_fee / MAX_BPS performance_fee: uint256 = gain * vault.performanceFee() / MAX_BPS governance_fee = governance_fee + strategist_fee + performance_fee @@ -159,27 +180,10 @@ def _calc031( return 0 @external -def sweep(token: address, amount: uint256): - """ - @notice - Allow a strategy to pull a token balance to offset losses. - @dev - Intended to transfer precise amount of want based on losses generated from fees. - Because a loss can increase block by block, this method allows us to atomically - airdrop an amount of want with some buffer, harvest the strategy, then sweep out - the any buffer atomically. - Token balances should be kept at 0 during normal operation. +def sweep(token: address): """ - assert self.approved_sweepers[msg.sender] # @dev: !approved - ERC20(token).transfer(msg.sender, amount, default_return_value=True) - -@external -def approve_sweepers(sweeper: address, approved: bool): - """ - @notice - Approve strategies to sweep + @notice Allow governance ms to sweep """ - ychad: address = 0xFEB4acf3df3cDEA7399794D0869ef76A6EfAff52 - ybrain: address = 0x16388463d60FFE0661Cf7F1f31a7D658aC790ff7 - assert msg.sender in [ychad, ybrain] # @dev: !approved - self.approved_sweepers[sweeper] = approved \ No newline at end of file + assert msg.sender == 0xFEB4acf3df3cDEA7399794D0869ef76A6EfAff52 # @dev: !approved + bal: uint256 = ERC20(token).balanceOf(self) + ERC20(token).transfer(msg.sender, bal, default_return_value=True) \ No newline at end of file diff --git a/contracts/RouterStrategy.sol b/contracts/RouterStrategy.sol index 3b76ec3..9c99324 100644 --- a/contracts/RouterStrategy.sol +++ b/contracts/RouterStrategy.sol @@ -13,8 +13,7 @@ import { import "@openzeppelin/contracts/math/Math.sol"; interface ILossChecker { - function checkLoss(uint, uint) external view returns (uint); - function sweep(address, uint) external; + function check_loss(uint, uint) external view returns (uint); } interface ISharesHelper { @@ -44,7 +43,8 @@ contract RouterStrategy is BaseStrategy { uint256 public feeLossTolerance; uint256 public maxLoss; bool internal isOriginal = true; - ISharesHelper public constant sharesHelper = ISharesHelper(0x444443bae5bB8640677A8cdF94CB8879Fec948Ec); + ISharesHelper public constant sharesHelper = + ISharesHelper(0x444443bae5bB8640677A8cdF94CB8879Fec948Ec); // CREATE2 generated address, deployable to all chains constructor( address _vault, @@ -175,10 +175,10 @@ contract RouterStrategy is BaseStrategy { _loss = 0; } - uint expectedLoss = lossChecker.checkLoss(_profit, _loss); - if (expectedLoss > feeLossTolerance){ + uint expectedLoss = lossChecker.check_loss(_profit, _loss); + uint _feeLossTolerance = feeLossTolerance; + if (expectedLoss > _feeLossTolerance){ require(want.balanceOf(address(lossChecker)) > expectedLoss, "LossyWithFees"); - lossChecker.sweep(address(want), expectedLoss); } } diff --git a/tests/conftest.py b/tests/conftest.py index f0583b6..5f2f7db 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -111,10 +111,6 @@ def weth_amout(user, weth): def health_check(): yield Contract("0xddcea799ff1699e98edf118e0629a974df7df012") -@pytest.fixture -def loss_checker(strategist): - yield strategist.deploy(LossOnFeeChecker) - @pytest.fixture def vault(pm, gov, rewards, guardian, management, token): Vault = pm(config["dependencies"][0]).Vault @@ -125,6 +121,11 @@ def vault(pm, gov, rewards, guardian, management, token): yield vault yield Contract("0xa9fE4601811213c340e850ea305481afF02f5b28") +@pytest.fixture +def loss_checker(strategist, gov): + loss_checker = strategist.deploy(LossOnFeeChecker) + # loss_checker.approve_sweeper(gov,True,{'from':gov}) + yield loss_checker @pytest.fixture def strategy( @@ -156,7 +157,7 @@ def strategy( strategy.setHealthCheck(health_check, {"from": origin_vault.governance()}) origin_vault.addStrategy(strategy, 10_000, 0, 0, {"from": gov}) - + # loss_checker.approve_sweeper(strategy,True,{'from':gov}) yield strategy @pytest.fixture diff --git a/tests/test_operation_steth.py b/tests/test_operation_steth.py index aa6608f..34ef9bc 100644 --- a/tests/test_operation_steth.py +++ b/tests/test_operation_steth.py @@ -1,9 +1,9 @@ import pytest -from brownie import Contract, ZERO_ADDRESS, Wei, chain, accounts +from brownie import Contract, ZERO_ADDRESS, Wei, chain, accounts, reverts def test_migrate(origin_vault, destination_vault, strategy, gov, loss_checker): - # hack :) + whale = accounts.at('0x99ac10631F69C753DDb595D074422a0922D9056B',force=True) old_vault = origin_vault new_vault = destination_vault old_strategy = Contract(old_vault.withdrawalQueue(0),owner=gov) @@ -13,24 +13,46 @@ def test_migrate(origin_vault, destination_vault, strategy, gov, loss_checker): # We only have to harvest into our router strat, which is already at 100% DR strategy.setFeeLossTolerance(1e18,{"from": gov}) strategy.harvest({"from": gov}) + chain.sleep(60 * 60 * 24 * 7) # No sells, means no profit yet # Let's set mgmt fee pretty high origin_vault.setManagementFee(500, {"from": gov}) - expectedLoss = loss_checker.checkLoss(0, 0, strategy) + expectedLoss = loss_checker.check_loss(0, 0, strategy) print(f'EXPECTED LOSS AMOUNT = {expectedLoss}') expected_loss = emulate_fees(strategy, origin_vault) + # Harvest should fail due to loss + with reverts('LossyWithFees'): + tx = strategy.harvest({"from": gov}) + + chain.sleep(60 * 60 * 24 * 7) + # Let's transfer some want to the checker that it can sweep + est = loss_checker.check_loss(0, 0, strategy) whale = accounts.at('0x99ac10631F69C753DDb595D074422a0922D9056B', force=True) want = Contract(origin_vault.token(),owner=whale) - # want.transfer(strategy, expected_loss + 1e17) + + pps = origin_vault.pricePerShare() + strategy.setFeeLossTolerance(100e18,{"from": gov}) + + tx = strategy.harvest({"from": gov}) + + strategy.setFeeLossTolerance(100e18,{"from": gov}) + # Airdrop to offset profit + want.transfer(origin_vault, est) + assert origin_vault.pricePerShare() == pps + totalShares = origin_vault.totalSupply() tx = strategy.harvest({"from": gov}) totalSharesAfter = origin_vault.totalSupply() - assert totalSharesAfter <= totalShares + assert totalSharesAfter <= totalShares + ( + strategy.feeLossTolerance() * + origin_vault.pricePerShare() / + 1e18 + ) print(tx.events['Harvested']) # origin_vault.updateStrategyDebtRatio(strategy, 10_000, {'from':gov}) From f6c090b2ef8e259faf3eeed92762ebcb982a3312 Mon Sep 17 00:00:00 2001 From: wavey0x Date: Wed, 8 Feb 2023 00:47:13 -0500 Subject: [PATCH 05/10] chore: remove balance check in revert logic --- contracts/RouterStrategy.sol | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/contracts/RouterStrategy.sol b/contracts/RouterStrategy.sol index 9c99324..b599cef 100644 --- a/contracts/RouterStrategy.sol +++ b/contracts/RouterStrategy.sol @@ -176,10 +176,7 @@ contract RouterStrategy is BaseStrategy { } uint expectedLoss = lossChecker.check_loss(_profit, _loss); - uint _feeLossTolerance = feeLossTolerance; - if (expectedLoss > _feeLossTolerance){ - require(want.balanceOf(address(lossChecker)) > expectedLoss, "LossyWithFees"); - } + require(feeLossTolerance >= expectedLoss, "LossyWithFees"); } function adjustPosition(uint256 _debtOutstanding) From 9942df10e3eac6f81420e0a4554a1ed8b9df3e1f Mon Sep 17 00:00:00 2001 From: wavey0x Date: Wed, 8 Feb 2023 00:51:34 -0500 Subject: [PATCH 06/10] chore: change var name from governance_fee to total_fee --- contracts/LossOnFeeChecker.vy | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/contracts/LossOnFeeChecker.vy b/contracts/LossOnFeeChecker.vy index 1d36323..5fba7a6 100644 --- a/contracts/LossOnFeeChecker.vy +++ b/contracts/LossOnFeeChecker.vy @@ -124,7 +124,7 @@ def _calc030( total_assets: uint256, time_since: uint256 ) -> uint256: - governance_fee: uint256 = ( + total_fee: uint256 = ( total_assets * time_since * management_fee / MAX_BPS / SECS_PER_YEAR @@ -133,17 +133,17 @@ def _calc030( if gain > 0: strategist_fee: uint256 = gain * strat_perf_fee / MAX_BPS performance_fee: uint256 = gain * vault.performanceFee() / MAX_BPS - governance_fee = governance_fee + strategist_fee + performance_fee + total_fee = total_fee + strategist_fee + performance_fee if gain >= loss: gross_profit: uint256 = gain - loss - if gross_profit >= governance_fee: + if gross_profit >= total_fee: return 0 else: - return governance_fee - gross_profit + return total_fee - gross_profit else: gross_loss: uint256 = loss - gain - return gross_loss + governance_fee + return gross_loss + total_fee return 0 @@ -158,7 +158,7 @@ def _calc031( time_since: uint256 ) -> uint256: vault_debt: uint256 = vault.totalDebt() - governance_fee: uint256 = ( + total_fee: uint256 = ( vault_debt * time_since * management_fee / MAX_BPS / SECS_PER_YEAR @@ -167,15 +167,15 @@ def _calc031( if gain > 0: strategist_fee: uint256 = gain * strat_perf_fee / MAX_BPS performance_fee: uint256 = gain * vault.performanceFee() / MAX_BPS - governance_fee = governance_fee + strategist_fee + performance_fee + total_fee = total_fee + strategist_fee + performance_fee if gain >= loss: gross_profit: uint256 = gain - loss - if gross_profit >= governance_fee: + if gross_profit >= total_fee: return 0 else: gross_loss: uint256 = loss - gain - return gross_loss + governance_fee + return gross_loss + total_fee return 0 From 31e4026657e93c745c029fd10f43d939ee036ece Mon Sep 17 00:00:00 2001 From: wavey0x Date: Wed, 8 Feb 2023 00:52:58 -0500 Subject: [PATCH 07/10] chore: remove solidity fee checker in favor of vyper --- contracts/LossOnFeeChecker.sol | 106 --------------------------------- 1 file changed, 106 deletions(-) delete mode 100644 contracts/LossOnFeeChecker.sol diff --git a/contracts/LossOnFeeChecker.sol b/contracts/LossOnFeeChecker.sol deleted file mode 100644 index 03a670d..0000000 --- a/contracts/LossOnFeeChecker.sol +++ /dev/null @@ -1,106 +0,0 @@ -pragma solidity 0.6.12; -pragma experimental ABIEncoderV2; - -import { - SafeERC20, - IERC20 -} from "@openzeppelin/contracts/token/ERC20/SafeERC20.sol"; - -interface IVault { - struct StrategyParams { - uint performanceFee; - uint activation; - uint debtRatio; - uint rateLimit; - uint lastReport; - uint totalDebt; - uint totalGain; - uint totalLoss; - } - function totalAssets() external view returns (uint); - function managementFee() external view returns (uint); - function performanceFee() external view returns (uint); - function strategies(address) external view returns (StrategyParams memory); - function lastReport() external view returns (uint); -} - -interface IStrategy { - function vault() external view returns (address); -} - -/// @title LossOnFeeChecker -/// @notice Designed to prevent Management fees from creating lossy reports on Yearn vaults with API < 0.3.5 -/// @dev Begining with vaults API v0.3.5 management fees are adjust dynamically on report to prevent loss -contract LossOnFeeCheckerSol { - using SafeERC20 for IERC20; - - uint constant MAX_BPS = 10_000; - uint constant SECS_PER_YEAR = 31_557_600; - mapping(address=>bool) public approvedSweepers; - - /// @notice Check if harvest does not contain a loss after fees - /// @dev should be called automically report transaction - function checkLoss(uint gain, uint loss) external view returns (uint) { - return _check(msg.sender, gain, loss); - } - - /// @notice For testing amounts and strategies not directly on chain - function checkLoss(address strategy, uint gain, uint loss) external view returns (uint) { - return _check(strategy, gain, loss); - } - - function _check(address _strategy, uint gain, uint loss) internal view returns (uint) { - IStrategy strategy = IStrategy(_strategy); - IVault vault = IVault(strategy.vault()); - - uint managementFee = vault.managementFee(); - if (managementFee == 0) return 0; - - uint totalAssets = vault.totalAssets(); - if (totalAssets == 0) return 0; - - uint lastReport = vault.lastReport(); - if (lastReport == 0) return 0; - - IVault.StrategyParams memory params = vault.strategies(msg.sender); - - uint timeSince = block.timestamp - lastReport; - if (timeSince == 0) return 0; - - uint governanceFee = ( - totalAssets * timeSince * managementFee - / MAX_BPS - / SECS_PER_YEAR - ); - - if (gain > 0) { - // These fees are applied only upon a profit - uint strategistFeeAmount = gain * params.performanceFee / MAX_BPS; - uint performanceFeeAmount = gain * vault.performanceFee() / MAX_BPS; - governanceFee = governanceFee + strategistFeeAmount + performanceFeeAmount; - } - - if (gain >= loss){ - uint grossProfit = gain - loss; - if (grossProfit >= governanceFee) return 0; - return governanceFee - grossProfit; - } - else{ - uint grossLoss = loss - gain; - return governanceFee + grossLoss; - } - } - - function sweep(address _token, uint _amount) external { - require(approvedSweepers[msg.sender], "!approved"); - IERC20(_token).transfer(msg.sender, _amount); - } - - function approveSweepers(address _sweeper, bool _approved) external { - address ychad = 0xFEB4acf3df3cDEA7399794D0869ef76A6EfAff52; - address brain = 0x16388463d60FFE0661Cf7F1f31a7D658aC790ff7; - require(msg.sender == ychad || msg.sender == brain); - approvedSweepers[_sweeper] = _approved; - } -} - From 47eefc76cc1654b6012be40699e7c92356879a6e Mon Sep 17 00:00:00 2001 From: wavey0x Date: Wed, 8 Feb 2023 11:47:59 -0500 Subject: [PATCH 08/10] chore: remove unused var --- contracts/LossOnFeeChecker.vy | 1 - 1 file changed, 1 deletion(-) diff --git a/contracts/LossOnFeeChecker.vy b/contracts/LossOnFeeChecker.vy index 5fba7a6..f27d74d 100644 --- a/contracts/LossOnFeeChecker.vy +++ b/contracts/LossOnFeeChecker.vy @@ -56,7 +56,6 @@ struct StrategyParamsNew: MAX_BPS: constant(uint256) = 10_000 SECS_PER_YEAR: constant(uint256) = 31_557_600 -approved_sweepers: public(HashMap[address, bool]) @external @view From 21f68b83261faee9952244adc72a5b09bfd5c96a Mon Sep 17 00:00:00 2001 From: wavey0x Date: Thu, 9 Feb 2023 17:30:08 -0500 Subject: [PATCH 09/10] fix: review fix --- contracts/LossOnFeeChecker.vy | 2 ++ 1 file changed, 2 insertions(+) diff --git a/contracts/LossOnFeeChecker.vy b/contracts/LossOnFeeChecker.vy index f27d74d..24c31a0 100644 --- a/contracts/LossOnFeeChecker.vy +++ b/contracts/LossOnFeeChecker.vy @@ -172,6 +172,8 @@ def _calc031( gross_profit: uint256 = gain - loss if gross_profit >= total_fee: return 0 + else: + return total_fee - gross_profit else: gross_loss: uint256 = loss - gain return gross_loss + total_fee From 5c269723210fcd21b337510838ee909cc28cd74a Mon Sep 17 00:00:00 2001 From: wavey0x Date: Wed, 1 Mar 2023 16:48:14 -0500 Subject: [PATCH 10/10] feat: hardcode checker as a constant --- contracts/RouterStrategy.sol | 13 ++++--------- tests/conftest.py | 5 +++-- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/contracts/RouterStrategy.sol b/contracts/RouterStrategy.sol index b599cef..4d77cf3 100644 --- a/contracts/RouterStrategy.sol +++ b/contracts/RouterStrategy.sol @@ -39,7 +39,7 @@ contract RouterStrategy is BaseStrategy { string internal strategyName; IVault public yVault; - ILossChecker public lossChecker; + ILossChecker public constant lossChecker = ILossChecker(0x6b6003d4Bc320Ed25E8E2be49600EC1006676239); uint256 public feeLossTolerance; uint256 public maxLoss; bool internal isOriginal = true; @@ -49,10 +49,9 @@ contract RouterStrategy is BaseStrategy { constructor( address _vault, address _yVault, - address _lossChecker, string memory _strategyName ) public BaseStrategy(_vault) { - _initializeThis(_yVault, _strategyName, _lossChecker); + _initializeThis(_yVault, _strategyName); } event Cloned(address indexed clone); @@ -63,7 +62,6 @@ contract RouterStrategy is BaseStrategy { address _rewards, address _keeper, address _yVault, - address _lossChecker, string memory _strategyName ) external virtual returns (address newStrategy) { require(isOriginal); @@ -90,7 +88,6 @@ contract RouterStrategy is BaseStrategy { _rewards, _keeper, _yVault, - _lossChecker, _strategyName ); @@ -103,20 +100,18 @@ contract RouterStrategy is BaseStrategy { address _rewards, address _keeper, address _yVault, - address _lossChecker, string memory _strategyName ) public { _initialize(_vault, _strategist, _rewards, _keeper); require(address(yVault) == address(0)); - _initializeThis(_yVault, _strategyName, _lossChecker); + _initializeThis(_yVault, _strategyName); } - function _initializeThis(address _yVault, string memory _strategyName, address _lossChecker) + function _initializeThis(address _yVault, string memory _strategyName) internal { yVault = IVault(_yVault); strategyName = _strategyName; - lossChecker = ILossChecker(_lossChecker); IERC20(address(want)).approve(_yVault, uint256(-1)); } diff --git a/tests/conftest.py b/tests/conftest.py index 5f2f7db..0a53caa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -123,8 +123,9 @@ def vault(pm, gov, rewards, guardian, management, token): @pytest.fixture def loss_checker(strategist, gov): - loss_checker = strategist.deploy(LossOnFeeChecker) + # loss_checker = strategist.deploy(LossOnFeeChecker) # loss_checker.approve_sweeper(gov,True,{'from':gov}) + loss_checker = Contract('0x6b6003d4Bc320Ed25E8E2be49600EC1006676239') yield loss_checker @pytest.fixture @@ -139,7 +140,7 @@ def strategy( health_check, ): strategy = strategist.deploy( - RouterStrategy, origin_vault, destination_vault, loss_checker, "Strat "+origin_vault.symbol() + RouterStrategy, origin_vault, destination_vault, "Strat "+origin_vault.symbol() ) strategy.setKeeper(keeper)