From adc4d67e25c038f59736c0a4684656e90e73a52e Mon Sep 17 00:00:00 2001 From: spalen0 <116267321+spalen0@users.noreply.github.com> Date: Tue, 6 Dec 2022 12:07:38 +0000 Subject: [PATCH] fix: rewards (#4) * fix: rewards * feat: add univ3 --- contracts/Strategy.sol | 130 +++++++++++------ contracts/interfaces/ISwapRouter.sol | 95 ++++++++++++ contracts/interfaces/IUniswapV2Router01.sol | 151 -------------------- contracts/interfaces/comp/ComptrollerI.sol | 18 +++ tests/conftest.py | 5 + tests/test_reward.py | 91 ++++++++++++ tests/test_strategy.py | 52 ++----- 7 files changed, 311 insertions(+), 231 deletions(-) create mode 100644 contracts/interfaces/ISwapRouter.sol delete mode 100644 contracts/interfaces/IUniswapV2Router01.sol create mode 100644 tests/test_reward.py diff --git a/contracts/Strategy.sol b/contracts/Strategy.sol index b0f4e50..48fae2b 100644 --- a/contracts/Strategy.sol +++ b/contracts/Strategy.sol @@ -7,7 +7,7 @@ import "@openzeppelin/contracts/access/Ownable.sol"; import {IERC20, BaseStrategy} from "BaseStrategy.sol"; import "./interfaces/IVault.sol"; -import "./interfaces/IUniswapV2Router01.sol"; +import "./interfaces/ISwapRouter.sol"; import "./interfaces/ITradeFactory.sol"; import "./interfaces/comp/CErc20I.sol"; import "./interfaces/comp/InterestRateModel.sol"; @@ -17,10 +17,15 @@ import "./interfaces/comp/UniswapAnchoredViewI.sol"; contract Strategy is BaseStrategy, Ownable { using SafeERC20 for IERC20; + //Uniswap v3 router + ISwapRouter internal constant UNISWAP_ROUTER = + ISwapRouter(0xE592427A0AEce92De3Edee1F18E0157C05861564); + //Fees for the V3 pools if the supply is incentivized + uint24 public compToEthFee; + uint24 public ethToWantFee; + // eth blocks are mined every 12s -> 3600 * 24 * 365 / 12 = 2_628_000 uint256 private constant BLOCKS_PER_YEAR = 2_628_000; - address public constant UNISWAP_ROUTER = - address(0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D); address public constant COMP = address(0xc00e94Cb662C3520282E6f5717214004A7f26888); address public constant WETH = @@ -68,11 +73,15 @@ contract Strategy is BaseStrategy, Ownable { _amountFreed = _amount; } else { // NOTE: we need the balance updated + // balanceOfUnderlying accrues interest in a transaction + uint256 balanceUnderlying = cToken.balanceOfUnderlying( + address(this) + ); // We need to take from Compound enough to reach _amount // We run with 'unchecked' as we are safe from underflow unchecked { _withdrawFromCompound( - Math.min(_amount - idleAmount, balanceOfCToken()) + Math.min(_amount - idleAmount, balanceUnderlying) ); } _amountFreed = balanceOfAsset(); @@ -152,45 +161,67 @@ contract Strategy is BaseStrategy, Ownable { function getRewardAprForSupplyBase( int256 newAmount ) public view returns (uint256) { + // COMP issued per block to suppliers * (1 * 10 ^ 18) + uint256 compSpeedPerBlock = COMPTROLLER.compSupplySpeeds( + address(cToken) + ); + if (compSpeedPerBlock == 0) { + return 0; + } + // Approximate COMP issued per year to suppliers * (1 * 10 ^ 18) + uint256 compSpeedPerYear = compSpeedPerBlock * BLOCKS_PER_YEAR; + // The price of the asset in USD as an unsigned integer scaled up by 10 ^ 6 uint256 rewardTokenPriceInUsd = PRICE_FEED.price("COMP"); + uint256 assetDecimals = IVault(vault).decimals(); + + // https://docs.compound.finance/v2/prices/#underlying-price // The price of the asset in USD as an unsigned integer scaled up by 10 ^ (36 - underlying asset decimals) - uint256 wantPriceInUsd = PRICE_FEED.getUnderlyingPrice(address(cToken)); + // upscale to price COMP percision 10 ^ 6 + uint256 wantPriceInUsd = PRICE_FEED.getUnderlyingPrice( + address(cToken) + ) / 10 ** (30 - assetDecimals); + uint256 cTokenTotalSupplyInWant = (cToken.totalSupply() * + cToken.exchangeRateStored()) / 1e18; uint256 wantTotalSupply = uint256( - int256(cToken.totalSupply()) + newAmount + int256(cTokenTotalSupplyInWant) + newAmount ); - // COMP issued per block to suppliers OR borrowers * (1 * 10 ^ 18) - uint256 compSpeed = COMPTROLLER.compSpeeds(address(cToken)); - // Approximate COMP issued per year to suppliers OR borrowers * (1 * 10 ^ 18) - uint256 compSpeedPerYear = compSpeed * BLOCKS_PER_YEAR; - // result 1e18 = 1e6 * 1e12 * 1e18 / 1e18 - uint256 supplyBaseRewardApr = (rewardTokenPriceInUsd * - 1e12 * - compSpeedPerYear) / (wantTotalSupply * wantPriceInUsd); - - uint256 decimals = IVault(vault).decimals(); - if (decimals < 18) { - // scale value to 1e18, see wantPriceInUsd scaling above - supplyBaseRewardApr = supplyBaseRewardApr / (10 ** (18 - decimals)); - } - return supplyBaseRewardApr; + return + (compSpeedPerYear * rewardTokenPriceInUsd * 10 ** assetDecimals) / + (wantTotalSupply * wantPriceInUsd); } /** * @notice Get pending COMP rewards for supplying want token + * @dev Pending rewards are update in comptroller afer every ctoken mint or redeem * @return Amount of pending COMP tokens */ function getRewardsPending() public view returns (uint256) { - return COMPTROLLER.compAccrued(address(this)); + // https://github.com/compound-finance/compound-protocol/blob/master/contracts/Comptroller.sol#L1230 + ComptrollerI.CompMarketState memory supplyState = COMPTROLLER + .compSupplyState(address(cToken)); + uint256 supplyIndex = supplyState.index; + uint256 supplierIndex = COMPTROLLER.compSupplierIndex( + address(cToken), + address(this) + ); + + // Calculate change in the cumulative sum of the COMP per cToken accrued + uint256 deltaIndex = supplyIndex - supplierIndex; + + // Calculate COMP accrued: cTokenAmount * accruedPerCToken / doubleScale + return (cToken.balanceOf(address(this)) * deltaIndex) / 1e36; } function harvest() external onlyOwner { - _claimRewards(); + if (getRewardsPending() > minCompToClaim) { + _claimRewards(); + } - if (tradeFactory == address(0)) { + if (tradeFactory == address(0) && compToEthFee != 0) { _disposeOfComp(); } @@ -208,34 +239,49 @@ contract Strategy is BaseStrategy, Ownable { * Claims the reward tokens due to this contract address */ function _claimRewards() internal { - if (COMPTROLLER.compAccrued(address(this)) > minCompToClaim) { - CTokenI[] memory cTokens = new CTokenI[](1); - cTokens[0] = cToken; - address[] memory holders = new address[](1); - holders[0] = address(this); - COMPTROLLER.claimComp(holders, cTokens, false, true); - } + CTokenI[] memory cTokens = new CTokenI[](1); + cTokens[0] = cToken; + address[] memory holders = new address[](1); + holders[0] = address(this); + COMPTROLLER.claimComp(holders, cTokens, false, true); } function _disposeOfComp() internal { uint256 compBalance = IERC20(COMP).balanceOf(address(this)); if (compBalance > minCompToSell) { - address[] memory path = new address[](3); - path[0] = COMP; - path[1] = WETH; - path[2] = IVault(vault).asset(); - - IUniswapV2Router01(UNISWAP_ROUTER).swapExactTokensForTokens( - compBalance, - uint256(0), - path, - address(this), - block.timestamp + bytes memory path = abi.encodePacked( + COMP, // comp-ETH + compToEthFee, + WETH, // ETH-want + ethToWantFee, + IVault(vault).asset() + ); + + // Proceeds from Comp are not subject to minExpectedSwapPercentage + // so they could get sandwiched if we end up in an uncle block + UNISWAP_ROUTER.exactInput( + ISwapRouter.ExactInputParams( + path, + address(this), + block.timestamp, + compBalance, + 0 + ) ); } } + //These will default to 0. + //Will need to be manually set if want is incentized before any harvests + function setUniFees( + uint24 _compToEth, + uint24 _ethToWant + ) external onlyOwner { + compToEthFee = _compToEth; + ethToWantFee = _ethToWant; + } + /** * @notice Set values for handling COMP reward token * @param _minCompToSell Minimum value that will be sold diff --git a/contracts/interfaces/ISwapRouter.sol b/contracts/interfaces/ISwapRouter.sol new file mode 100644 index 0000000..4ffa171 --- /dev/null +++ b/contracts/interfaces/ISwapRouter.sol @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity 0.8.14; + +/// @title Callback for IUniswapV3PoolActions#swap +/// @notice Any contract that calls IUniswapV3PoolActions#swap must implement this interface +interface IUniswapV3SwapCallback { + /// @notice Called to `msg.sender` after executing a swap via IUniswapV3Pool#swap. + /// @dev In the implementation you must pay the pool tokens owed for the swap. + /// The caller of this method must be checked to be a UniswapV3Pool deployed by the canonical UniswapV3Factory. + /// amount0Delta and amount1Delta can both be 0 if no tokens were swapped. + /// @param amount0Delta The amount of token0 that was sent (negative) or must be received (positive) by the pool by + /// the end of the swap. If positive, the callback must send that amount of token0 to the pool. + /// @param amount1Delta The amount of token1 that was sent (negative) or must be received (positive) by the pool by + /// the end of the swap. If positive, the callback must send that amount of token1 to the pool. + /// @param data Any data passed through by the caller via the IUniswapV3PoolActions#swap call + function uniswapV3SwapCallback( + int256 amount0Delta, + int256 amount1Delta, + bytes calldata data + ) external; +} + +/// @title Router token swapping functionality +/// @notice Functions for swapping tokens via Uniswap V3 +interface ISwapRouter is IUniswapV3SwapCallback { + struct ExactInputSingleParams { + address tokenIn; + address tokenOut; + uint24 fee; + address recipient; + uint256 deadline; + uint256 amountIn; + uint256 amountOutMinimum; + uint160 sqrtPriceLimitX96; + } + + /// @notice Swaps `amountIn` of one token for as much as possible of another token + /// @param params The parameters necessary for the swap, encoded as `ExactInputSingleParams` in calldata + /// @return amountOut The amount of the received token + function exactInputSingle( + ExactInputSingleParams calldata params + ) external payable returns (uint256 amountOut); + + struct ExactInputParams { + bytes path; + address recipient; + uint256 deadline; + uint256 amountIn; + uint256 amountOutMinimum; + } + + /// @notice Swaps `amountIn` of one token for as much as possible of another along the specified path + /// @param params The parameters necessary for the multi-hop swap, encoded as `ExactInputParams` in calldata + /// @return amountOut The amount of the received token + function exactInput( + ExactInputParams calldata params + ) external payable returns (uint256 amountOut); + + struct ExactOutputSingleParams { + address tokenIn; + address tokenOut; + uint24 fee; + address recipient; + uint256 deadline; + uint256 amountOut; + uint256 amountInMaximum; + uint160 sqrtPriceLimitX96; + } + + /// @notice Swaps as little as possible of one token for `amountOut` of another token + /// @param params The parameters necessary for the swap, encoded as `ExactOutputSingleParams` in calldata + /// @return amountIn The amount of the input token + function exactOutputSingle( + ExactOutputSingleParams calldata params + ) external payable returns (uint256 amountIn); + + struct ExactOutputParams { + bytes path; + address recipient; + uint256 deadline; + uint256 amountOut; + uint256 amountInMaximum; + } + + /// @notice Swaps as little as possible of one token for `amountOut` of another along the specified path (reversed) + /// @param params The parameters necessary for the multi-hop swap, encoded as `ExactOutputParams` in calldata + /// @return amountIn The amount of the input token + function exactOutput( + ExactOutputParams calldata params + ) external payable returns (uint256 amountIn); + + // Taken from https://soliditydeveloper.com/uniswap3 + // Manually added to the interface + function refundETH() external payable; +} diff --git a/contracts/interfaces/IUniswapV2Router01.sol b/contracts/interfaces/IUniswapV2Router01.sol deleted file mode 100644 index 3a33382..0000000 --- a/contracts/interfaces/IUniswapV2Router01.sol +++ /dev/null @@ -1,151 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0 -pragma solidity 0.8.14; - -interface IUniswapV2Router01 { - function factory() external pure returns (address); - - function WETH() external pure returns (address); - - function addLiquidity( - address tokenA, - address tokenB, - uint256 amountADesired, - uint256 amountBDesired, - uint256 amountAMin, - uint256 amountBMin, - address to, - uint256 deadline - ) external returns (uint256 amountA, uint256 amountB, uint256 liquidity); - - function addLiquidityETH( - address token, - uint256 amountTokenDesired, - uint256 amountTokenMin, - uint256 amountETHMin, - address to, - uint256 deadline - ) - external - payable - returns (uint256 amountToken, uint256 amountETH, uint256 liquidity); - - function removeLiquidity( - address tokenA, - address tokenB, - uint256 liquidity, - uint256 amountAMin, - uint256 amountBMin, - address to, - uint256 deadline - ) external returns (uint256 amountA, uint256 amountB); - - function removeLiquidityETH( - address token, - uint256 liquidity, - uint256 amountTokenMin, - uint256 amountETHMin, - address to, - uint256 deadline - ) external returns (uint256 amountToken, uint256 amountETH); - - function removeLiquidityWithPermit( - address tokenA, - address tokenB, - uint256 liquidity, - uint256 amountAMin, - uint256 amountBMin, - address to, - uint256 deadline, - bool approveMax, - uint8 v, - bytes32 r, - bytes32 s - ) external returns (uint256 amountA, uint256 amountB); - - function removeLiquidityETHWithPermit( - address token, - uint256 liquidity, - uint256 amountTokenMin, - uint256 amountETHMin, - address to, - uint256 deadline, - bool approveMax, - uint8 v, - bytes32 r, - bytes32 s - ) external returns (uint256 amountToken, uint256 amountETH); - - function swapExactTokensForTokens( - uint256 amountIn, - uint256 amountOutMin, - address[] calldata path, - address to, - uint256 deadline - ) external returns (uint256[] memory amounts); - - function swapTokensForExactTokens( - uint256 amountOut, - uint256 amountInMax, - address[] calldata path, - address to, - uint256 deadline - ) external returns (uint256[] memory amounts); - - function swapExactETHForTokens( - uint256 amountOutMin, - address[] calldata path, - address to, - uint256 deadline - ) external payable returns (uint256[] memory amounts); - - function swapTokensForExactETH( - uint256 amountOut, - uint256 amountInMax, - address[] calldata path, - address to, - uint256 deadline - ) external returns (uint256[] memory amounts); - - function swapExactTokensForETH( - uint256 amountIn, - uint256 amountOutMin, - address[] calldata path, - address to, - uint256 deadline - ) external returns (uint256[] memory amounts); - - function swapETHForExactTokens( - uint256 amountOut, - address[] calldata path, - address to, - uint256 deadline - ) external payable returns (uint256[] memory amounts); - - function quote( - uint256 amountA, - uint256 reserveA, - uint256 reserveB - ) external pure returns (uint256 amountB); - - function getAmountOut( - uint256 amountIn, - uint256 reserveIn, - uint256 reserveOut - ) external pure returns (uint256 amountOut); - - function getAmountIn( - uint256 amountOut, - uint256 reserveIn, - uint256 reserveOut - ) external pure returns (uint256 amountIn); - - function getAmountsOut( - uint256 amountIn, - address[] calldata path - ) external view returns (uint256[] memory amounts); - - function getAmountsIn( - uint256 amountOut, - address[] calldata path - ) external view returns (uint256[] memory amounts); -} diff --git a/contracts/interfaces/comp/ComptrollerI.sol b/contracts/interfaces/comp/ComptrollerI.sol index 7299c07..5e34611 100644 --- a/contracts/interfaces/comp/ComptrollerI.sol +++ b/contracts/interfaces/comp/ComptrollerI.sol @@ -150,4 +150,22 @@ interface ComptrollerI { ) external view returns (bool, uint256, bool); function compSpeeds(address ctoken) external view returns (uint256); + + function compSupplySpeeds(address ctoken) external view returns (uint256); + + struct CompMarketState { + // The market's last updated compBorrowIndex or compSupplyIndex + uint224 index; + // The block number the index was last updated at + uint32 block; + } + + function compSupplyState( + address + ) external view returns (CompMarketState memory); + + function compSupplierIndex( + address, + address + ) external view returns (uint256); } diff --git a/tests/conftest.py b/tests/conftest.py index 0208c41..118dde8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -41,6 +41,11 @@ def comp_whale(): yield accounts[COMP_WHALE_ADDRESS] +@pytest.fixture(scope="session") +def asset_whale(): + yield accounts[ASSET_WHALE_ADDRESS] + + @pytest.fixture(scope="session") def amount(asset): # Use 1M diff --git a/tests/test_reward.py b/tests/test_reward.py new file mode 100644 index 0000000..2ea3f2d --- /dev/null +++ b/tests/test_reward.py @@ -0,0 +1,91 @@ +from ape import chain +import pytest +from utils.constants import MAX_INT + + +def test_rewards_selling( + asset, + ctoken, + user, + create_vault_and_strategy, + gov, + strategist, + amount, + provide_strategy_with_debt, + comp, + comp_whale, +): + vault, strategy = create_vault_and_strategy(gov, amount) + new_debt = amount + provide_strategy_with_debt(gov, strategy, vault, new_debt) + + before_bal = strategy.totalAssets() + + reward = 2 * 10 ** comp.decimals() + comp.transfer(strategy, reward, sender=comp_whale) + assert comp.balanceOf(strategy) == reward + + # Set uni fees + strategy.setUniFees(3000, 500, sender=strategist) + + # harvest function should still work and will swap rewards any rewards + strategy.harvest(sender=strategist) + + # rewards should be sold + assert strategy.totalAssets() > before_bal + assert comp.balanceOf(strategy) == 0 + + +def test_rewards_apr(strategy, asset): + # get apr in percentage (100 / 1e18) + apr = strategy.getRewardAprForSupplyBase(0) / 1e16 + # for current apr visit compound website: https://v2-app.compound.finance/ + assert apr < 1 # all rewards are less than 1% + assert apr > 0.1 # all rewards are higher than 0.1% + # supplying more capital should reward in smaller rewards + assert strategy.getRewardAprForSupplyBase(0) > strategy.getRewardAprForSupplyBase( + 1000 * 10 ** asset.decimals() + ) + + +def test_rewards_pending( + asset, + ctoken, + user, + create_vault_and_strategy, + gov, + strategist, + amount, + provide_strategy_with_debt, + comp, + asset_whale, +): + vault, strategy = create_vault_and_strategy(gov, amount) + new_debt = amount + provide_strategy_with_debt(gov, strategy, vault, new_debt) + + # Don't sell rewards nor claim + strategy.setRewardStuff(MAX_INT, MAX_INT, sender=strategist) + + strategy.harvest(sender=strategist) + + # Take some time for rewards to accrue + chain.mine(3600 * 24 * 10) + + # Somebody deposits to trigger to rewards calculation + asset.approve(vault.address, amount, sender=asset_whale) + ctoken.mint(amount, sender=asset_whale) + + # rewards should be sold + rewards_pending = strategy.getRewardsPending() + assert rewards_pending > 0 + assert comp.balanceOf(strategy) == 0 + + # Don't sell rewards but claim all + strategy.setRewardStuff(MAX_INT, 1, sender=strategist) + + # harvest function should still work and will swap rewards any rewards + strategy.harvest(sender=strategist) + + assert comp.balanceOf(strategy) >= rewards_pending + assert comp.balanceOf(strategy) < rewards_pending * 1.1 diff --git a/tests/test_strategy.py b/tests/test_strategy.py index 12cecc3..d0ec737 100644 --- a/tests/test_strategy.py +++ b/tests/test_strategy.py @@ -270,16 +270,22 @@ def test_apr( provide_strategy_with_debt(gov, strategy, vault, new_debt) current_real_apr = ctoken.supplyRatePerBlock() * BLOCKS_PER_YEAR - current_expected_apr = strategy.aprAfterDebtChange(0) - assert pytest.approx(current_real_apr, rel=1e-5) == current_expected_apr + current_expected_apr_without_rewards = strategy.aprAfterDebtChange( + 0 + ) - strategy.getRewardAprForSupplyBase(0) + assert ( + pytest.approx(current_real_apr, rel=1e-5) + == current_expected_apr_without_rewards + ) # TODO: is there a way to re calculate without replicating in python? - assert current_real_apr < strategy.aprAfterDebtChange(-int(1e12)) - assert current_real_apr > strategy.aprAfterDebtChange(int(1e12)) - - # Supply is not currently incentivized - assert strategy.getRewardAprForSupplyBase(0) == 0 - assert strategy.getRewardsPending() == 0 + assert current_real_apr < strategy.aprAfterDebtChange( + -int(1e12) + ) - strategy.getRewardAprForSupplyBase(-int(1e12)) + assert current_real_apr > strategy.aprAfterDebtChange( + int(1e12) + ) - strategy.getRewardAprForSupplyBase(int(1e12)) + assert strategy.getRewardAprForSupplyBase(0) > 0 def test_harvest( @@ -310,33 +316,3 @@ def test_harvest( # no rewards should be claimed but the call accrues the account so we should be slightly higher assert strategy.totalAssets() > before_bal - - -def test_reward( - asset, - ctoken, - user, - create_vault_and_strategy, - gov, - strategist, - amount, - provide_strategy_with_debt, - comp, - comp_whale, -): - vault, strategy = create_vault_and_strategy(gov, amount) - new_debt = amount - provide_strategy_with_debt(gov, strategy, vault, new_debt) - - before_bal = strategy.totalAssets() - - reward = 2 * 10 ** comp.decimals() - comp.transfer(strategy, reward, sender=comp_whale) - assert comp.balanceOf(strategy) == reward - - # harvest function should still work and will swap rewards any rewards - strategy.harvest(sender=strategist) - - # rewards should be claimed but the call doesn't accrues the cToken value - assert strategy.totalAssets() > before_bal - assert comp.balanceOf(strategy) == 0