diff --git a/cSpell.json b/cSpell.json index 6736f087..71cea5fc 100644 --- a/cSpell.json +++ b/cSpell.json @@ -85,7 +85,8 @@ "ytoken", "yusdc", "yweth", - "speedbump" + "speedbump", + "Metapool" ], "enabledLanguageIds": [ "asciidoc", diff --git a/contracts/ConvexAssetProxy.sol b/contracts/ConvexAssetProxy.sol new file mode 100644 index 00000000..84ea0cf7 --- /dev/null +++ b/contracts/ConvexAssetProxy.sol @@ -0,0 +1,496 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +// Wrapped Position's constructor takes a specific IERC20 contract so we import and rename it here +// We import IERC20 from OZ so we can use `safe{func}` functions. +import { WrappedPosition, IERC20 as WIERC20 } from "./WrappedPosition.sol"; +import "./libraries/Authorizable.sol"; +import "./interfaces/external/IConvexBooster.sol"; +import "./interfaces/external/IConvexBaseRewardPool.sol"; +import "./interfaces/external/ISwapRouter.sol"; +import "./interfaces/external/I3CurvePoolDepositZap.sol"; +import { SafeERC20, IERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +// A little hacky, but solidity complains when trying to import different IERC20 interfaces +interface IERC20Decimals { + function decimals() external view returns (uint8); +} + +/** + * @title Convex Asset Proxy + * @notice Proxy for depositing Curve LP shares into Convex's system, and providing a shares based abstraction of ownership + * @notice Integrating with Curve is quite messy due to non-standard interfaces. Some of the logic below is specific to 3CRV-LUSD + */ +contract ConvexAssetProxy is WrappedPosition, Authorizable { + using SafeERC20 for IERC20; + /************************************************ + * STORAGE + ***********************************************/ + /// @notice The total supply of shares + /// This is needed here since ERC20.sol in WrappedPosition does not track totalSupply + uint256 public totalSupply; + + /// @notice whether this proxy is paused or not + bool public paused; + + /// @notice % fee keeper collects when calling harvest(). + /// Upper bound is 1000 (i.e 25 would be 2.5% of the total rewards) + uint256 public keeperFee; + + /// @notice Contains multi-hop Uniswap V3 paths for trading CRV, CVX, & any other reward tokens + /// index 0 is CRV path, index 1 is CVX path + bytes[] public swapPaths; + + /************************************************ + * IMMUTABLES & CONSTANTS + ***********************************************/ + /// @notice 3 pool curve zap (deposit contract) + I3CurvePoolDepositZap public immutable curveZap; + + /// @notice specific pool that the zapper will deposit into under the hood + address public immutable curveMetaPool; + + /// @notice the pool id (in Convex's system) of the underlying token + uint256 public immutable pid; + + /// @notice address of the convex Booster contract + IConvexBooster public immutable booster; + + /// @notice address of the convex rewards contract + IConvexBaseRewardPool public immutable rewardsContract; + + /// @notice Address of the deposit token 'reciepts' that are given to us + /// by the booster contract when we deposit the underlying token + IERC20 public immutable convexDepositToken; + + /// @notice Uniswap V3 router contract + ISwapRouter public immutable router; + + /// @notice address of CRV, CVX, DAI, USDC, USDT + IERC20 public constant crv = + IERC20(0xD533a949740bb3306d119CC777fa900bA034cd52); + IERC20 public constant cvx = + IERC20(0x4e3FBD56CD56c3e72c1403e103b45Db9da5B9D2B); + IERC20 public constant dai = + IERC20(0x6B175474E89094C44Da98b954EedeAC495271d0F); + IERC20 public constant usdc = + IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48); + IERC20 public constant usdt = + IERC20(0xdAC17F958D2ee523a2206206994597C13D831ec7); + + /************************************************ + * EVENTS, STRUCTS, MODIFIERS + ***********************************************/ + /// @notice emit when pause status changed + event PauseStatusChanged(bool indexed pauseStatus); + + /// @notice emit when keeper fee changed + event KeeperFeeChanged(uint256 newFee); + + /// @notice emit when a swap path is changed + event SwapPathChanged(uint256 indexed index, bytes path); + + /// @notice emit on a harvest + event Harvested(address harvester, uint256 underlyingHarvested); + + /// @notice emit on a sweep + event Sweeped(address destination, address[] tokensSweeped); + + /// @notice struct that helps define parameters for a swap + struct SwapHelper { + address token; // reward token we are swapping + uint256 deadline; + uint256 amountOutMinimum; + } + + /// @notice helper in constructor to avoid stack too deep + /** + * curveZap - address of 3pool Deposit Zap + * curveMetaPool - underlying curve pool + * booster address of convex booster for underlying token + * rewardsContract address of convex rewards contract for underlying token + * convexDepositToken address of convex deposit token reciept minted by booster + * router address of Uniswap v3 router + * pid pool id of the underlying token (in the context of Convex's system) + * keeperFee the fee that a keeper recieves from calling harvest() + */ + struct constructorParams { + I3CurvePoolDepositZap curveZap; + address curveMetaPool; + IConvexBooster booster; + IConvexBaseRewardPool rewardsContract; + address convexDepositToken; + ISwapRouter router; + uint256 pid; + uint256 keeperFee; + } + + /** + * @notice Sets immutables & storage variables + * @dev we use a struct to pack variables to avoid a stack too deep error + * @param _constructorParams packing variables to avoid stack error - see struct natspec comments + * @param _crvSwapPath swap path for CRV token + * @param _cvxSwapPath swap path for CVX token + * @param _token The underlying token. This token should revert in the event of a transfer failure + * @param _name The name of the token (shares) created by this contract + * @param _symbol The symbol of the token (shares) created by this contract + * @param _governance Governance address that can perform critical functions + * @param _pauser Address that can pause this contract + */ + constructor( + constructorParams memory _constructorParams, + bytes memory _crvSwapPath, + bytes memory _cvxSwapPath, + address _token, + string memory _name, + string memory _symbol, + address _governance, + address _pauser + ) WrappedPosition(WIERC20(_token), _name, _symbol) Authorizable() { + // Authorize the pauser + _authorize(_pauser); + // set the owner + setOwner(_governance); + // Set curve zap contract + curveZap = _constructorParams.curveZap; + // Set the metapool + curveMetaPool = _constructorParams.curveMetaPool; + // Set the booster + booster = _constructorParams.booster; + // Set the rewards contract + rewardsContract = _constructorParams.rewardsContract; + // Set convexDepositToken + convexDepositToken = IERC20(_constructorParams.convexDepositToken); + // Set uni v3 router address + router = _constructorParams.router; + // Set the pool id + pid = _constructorParams.pid; + // set keeper fee + keeperFee = _constructorParams.keeperFee; + // Add the swap paths + _addSwapPath(_crvSwapPath); + _addSwapPath(_cvxSwapPath); + // Approve the booster so it can pull tokens from this address + IERC20(_token).safeApprove( + address(_constructorParams.booster), + type(uint256).max + ); + + // We want our shares decimals to be the same as the convex deposit token decimals + require( + decimals == + IERC20Decimals(_constructorParams.convexDepositToken) + .decimals(), + "Inconsistent decimals" + ); + } + + /// @notice Checks that the contract has not been paused + modifier notPaused() { + require(!paused, "Paused"); + _; + } + + /** + * @notice Deposits underlying token into booster contract & auto stakes the deposit tokens received in the rewardContract + * @return Tuple (the shares to mint, amount of underlying token deposited) + */ + function _deposit() internal override notPaused returns (uint256, uint256) { + // Get the amount deposited + uint256 amount = token.balanceOf(address(this)); + + // // See how many deposit tokens we currently have + // uint256 depositTokensBefore = rewardsContract.balanceOf(address(this)); + + // Shares to be minted = (amount deposited * total shares) / total underlying token controlled by this contract + // Note that convex deposit receipt tokens and underlying are in a 1:1 relationship + // i.e for every 1 underlying we deposit we'd be credited with 1 deposit receipt token + // So we can calculate the total amount deposited in underlying by querying for our balance of deposit receipt token + uint256 sharesToMint; + if (totalSupply != 0) { + sharesToMint = + (amount * totalSupply) / + rewardsContract.balanceOf(address(this)); + } else { + // Reach this case if we have no shares + sharesToMint = amount; + } + // Update our totalSupply + totalSupply += sharesToMint; + + // Deposit underlying tokens + // Last boolean indicates whether we want the Booster to auto-stake our deposit tokens in the reward contract for us + booster.deposit(pid, amount, true); + + // Return the amount of shares the user has produced, and the amount used for it. + return (sharesToMint, amount); + } + + /** + * @notice Calculates the amount of underlying token out & transfers it to _destination + * @dev Shares must be burned AFTER this function is called to ensure bookkeeping is correct + * @param _shares The number of wrapped position shares to withdraw + * @param _destination The address to send the output funds + * @return returns the amount of underlying tokens withdrawn + */ + function _withdraw( + uint256 _shares, + address _destination, + uint256 + ) internal override notPaused returns (uint256) { + // We need to withdraw from the rewards contract & send to the destination + // Boolean indicates that we don't want to collect rewards (this saves the user gas) + uint256 amountUnderlyingToWithdraw = _underlying(_shares); + rewardsContract.withdrawAndUnwrap(amountUnderlyingToWithdraw, false); + + // Update our totalSupply + totalSupply -= _shares; + + // Transfer underlying LP tokens to user + token.transfer(_destination, amountUnderlyingToWithdraw); + + // Return the amount of underlying + return amountUnderlyingToWithdraw; + } + + /** + * @notice Get the underlying amount of tokens per shares given + * @param _shares The amount of shares you want to know the value of + * @return Value of shares in underlying token + */ + function _underlying(uint256 _shares) + internal + view + override + returns (uint256) + { + return (_shares * _pricePerShare()) / (10**decimals); + } + + /** + * @notice Get the amount of underlying per share in the vault + * @return returns the amount of underlying tokens per share + */ + function _pricePerShare() internal view returns (uint256) { + // Underlying per share = (1 / total Shares) * total amount of underlying controlled + return + ((10**decimals) * rewardsContract.balanceOf(address(this))) / + totalSupply; + } + + /** + * @notice Reset approval for booster contract + */ + function approve() external { + // We need to reset to 0 and then approve again + // see https://curve.readthedocs.io/exchange-lp-tokens.html#CurveToken.approve + token.approve(address(booster), 0); + token.approve(address(booster), type(uint256).max); + } + + /** + * @notice Allows an authorized address or the owner to pause this contract + * @param pauseStatus true for paused, false for not paused + * @dev the caller must be authorized + */ + function pause(bool pauseStatus) external onlyAuthorized { + paused = pauseStatus; + emit PauseStatusChanged(pauseStatus); + } + + /** + * @notice sets a new keeper fee, only callable by owner + * @param newFee the new keeper fee to set + */ + function setKeeperFee(uint256 newFee) external onlyOwner { + keeperFee = newFee; + emit KeeperFeeChanged(newFee); + } + + /** + * @notice Add a swap path + * @param path new path to use for swapping + */ + function _addSwapPath(bytes memory path) internal { + // Push dummy path to expand array, then call setPath + swapPaths.push(""); + _setSwapPath(swapPaths.length - 1, path); + } + + /** + * @notice Allows an authorized address to add a swap path + * @param path new path to use for swapping + * @dev the caller must be authorized + */ + function addSwapPath(bytes memory path) external onlyAuthorized { + _addSwapPath(path); + } + + /** + * @notice Allows an authorized address to delete a swap path + * @dev note we only allow deleting the last path to avoid a gap in our array + * If a path besides the last path must be deleted, deletePath & addSwapPath will have to be called + * in an appropriate order + */ + function deleteSwapPath() external onlyAuthorized { + delete swapPaths[swapPaths.length - 1]; + } + + /** + * @notice Sets a new swap path + * @param index index in swapPaths array to overwrite + * @param path new path to use for swapping + */ + function _setSwapPath(uint256 index, bytes memory path) internal { + // Multihop paths are of the form [tokenA, fee, tokenB, fee, tokenC, ... finalToken] + // Let's ensure that a compromised authorized address cannot rug + // by verifying that the input & output tokens are whitelisted (ie output is part of 3CRV pool - DAI, USDC, or USDT) + address inputToken; + address outputToken; + uint256 lengthOfPath = path.length; + assembly { + // skip length (first 32 bytes) to load in the next 32 bytes. Now truncate to get only first 20 bytes + // Address is 20 bytes, and truncates by taking the last 20 bytes of a 32 byte word. + // So, we shift right by 12 bytes (96 bits) + inputToken := shr(96, mload(add(path, 0x20))) + // get the last 20 bytes of path + // This is skip first 32 bytes, move to end of path array, then move back 20 to start of final outputToken address + // Truncate to only get first 20 bytes + outputToken := shr( + 96, + mload(sub(add(add(path, 0x20), lengthOfPath), 0x14)) + ) + } + + if (index == 0 || index == 1) { + require( + inputToken == address(crv) || inputToken == address(cvx), + "Invalid input token" + ); + } + + require( + outputToken == address(dai) || + outputToken == address(usdc) || + outputToken == address(usdt), + "Invalid output token" + ); + + // Set the swap path + swapPaths[index] = path; + emit SwapPathChanged(index, path); + } + + /** + * @notice Allows an authorized address to set the swap path for this contract + * @param index index in swapPaths array to overwrite + * @param path new path to use for swapping + * @dev the caller must be authorized + */ + function setSwapPath(uint256 index, bytes memory path) + public + onlyAuthorized + { + _setSwapPath(index, path); + } + + /** + * @notice approves curve zap (deposit) contract for all 3 stable coins + * @dev note that safeApprove requires us to set approval to 0 & then the desired value + */ + function _approveAll() internal { + dai.safeApprove(address(curveZap), 0); + dai.safeApprove(address(curveZap), type(uint256).max); + usdc.safeApprove(address(curveZap), 0); + usdc.safeApprove(address(curveZap), type(uint256).max); + usdt.safeApprove(address(curveZap), 0); + usdt.safeApprove(address(curveZap), type(uint256).max); + } + + /** + * @notice harvest logic to collect rewards in CRV, CVX, etc. The caller will receive a % of rewards (set by keeperFee) + * @param swapHelpers a list of structs, one for each swap to be made, defining useful parameters + * @dev keeper will receive all rewards in the underlying token + * @dev most importantly, each SwapParams should have a reasonable amountOutMinimum to prevent egregious sandwich attacks or frontrunning + * @dev we must have a swapPaths path for each reward token we wish to swap + */ + function harvest(SwapHelper[] memory swapHelpers) external onlyAuthorized { + // Collect our rewards, will also collect extra rewards + rewardsContract.getReward(); + + SwapHelper memory currParamHelper; + ISwapRouter.ExactInputParams memory params; + uint256 rewardTokenEarned; + + // Let's swap all the tokens we need to + for (uint256 i = 0; i < swapHelpers.length; i++) { + currParamHelper = swapHelpers[i]; + IERC20 rewardToken = IERC20(currParamHelper.token); + + // Check to make sure that this isn't the underlying token or the deposit token + require( + address(rewardToken) != address(token) && + address(rewardToken) != address(convexDepositToken), + "Attempting to swap underlying or deposit token" + ); + + rewardTokenEarned = rewardToken.balanceOf(address(this)); + if (rewardTokenEarned > 0) { + // Approve router to use our rewardToken + rewardToken.safeApprove(address(router), rewardTokenEarned); + + // Create params for the swap + currParamHelper = swapHelpers[i]; + params = ISwapRouter.ExactInputParams({ + path: swapPaths[i], + recipient: address(this), + deadline: currParamHelper.deadline, + amountIn: rewardTokenEarned, + amountOutMinimum: currParamHelper.amountOutMinimum + }); + router.exactInput(params); + } + } + + // First give approval to the curve zap contract to access our stable coins + _approveAll(); + uint256 daiBalance = dai.balanceOf(address(this)); + uint256 usdcBalance = usdc.balanceOf(address(this)); + uint256 usdtBalance = usdt.balanceOf(address(this)); + curveZap.add_liquidity( + curveMetaPool, + [0, daiBalance, usdcBalance, usdtBalance], + 0 + ); + + // See how many underlying tokens we received + uint256 underlyingReceived = token.balanceOf(address(this)); + // Transfer keeper Fee to msg.sender + // Bounty = (keeper Fee / 1000) * underlying Received + uint256 bounty = (keeperFee * underlyingReceived) / 1e3; + token.transfer(msg.sender, bounty); + + // Now stake the newly recieved underlying to the booster contract + booster.deposit(pid, token.balanceOf(address(this)), true); + emit Harvested(msg.sender, underlyingReceived); + } + + /** + * @notice sweeps this contract to rescue any tokens that we do not handle + * Could deal with reward tokens we didn't account for, airdropped tokens, etc. + * @param tokensToSweep array of token address to transfer to destination + * @param destination the address to send all recovered tokens to + */ + function sweep(address[] memory tokensToSweep, address destination) + external + onlyOwner + { + for (uint256 i = 0; i < tokensToSweep.length; i++) { + IERC20(tokensToSweep[i]).safeTransfer( + destination, + IERC20(tokensToSweep[i]).balanceOf(address(this)) + ); + } + emit Sweeped(destination, tokensToSweep); + } +} diff --git a/contracts/interfaces/external/I3CurvePoolDepositZap.sol b/contracts/interfaces/external/I3CurvePoolDepositZap.sol new file mode 100644 index 00000000..7b45fe0c --- /dev/null +++ b/contracts/interfaces/external/I3CurvePoolDepositZap.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +interface I3CurvePoolDepositZap { + function add_liquidity( + address metapool, + uint256[4] memory amountCtx, + uint256 minAmount + ) external payable; +} diff --git a/contracts/interfaces/external/IConvexBaseRewardPool.sol b/contracts/interfaces/external/IConvexBaseRewardPool.sol new file mode 100644 index 00000000..9c825f5d --- /dev/null +++ b/contracts/interfaces/external/IConvexBaseRewardPool.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +// Convex BaseRewardsPool.sol interface +interface IConvexBaseRewardPool { + //get balance of an address + function balanceOf(address _account) external view returns (uint256); + + //withdraw to a convex tokenized deposit + function withdraw(uint256 _amount, bool _claim) external returns (bool); + + //withdraw directly to curve LP token + function withdrawAndUnwrap(uint256 _amount, bool _claim) + external + returns (bool); + + function withdrawAll(bool claim) external; + + function withdrawAllAndUnwrap(bool claim) external; + + //claim rewards + function getReward() external returns (bool); + + //stake a convex tokenized deposit + function stake(uint256 _amount) external returns (bool); + + //stake a convex tokenized deposit for another address(transfering ownership) + function stakeFor(address _account, uint256 _amount) + external + returns (bool); + + function extraRewardsLength() external view returns (uint256); + + function earned(address account) external view returns (uint256); + + function stakeAll() external returns (bool); +} diff --git a/contracts/interfaces/external/IConvexBooster.sol b/contracts/interfaces/external/IConvexBooster.sol new file mode 100644 index 00000000..5e4719b5 --- /dev/null +++ b/contracts/interfaces/external/IConvexBooster.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +//main Convex contract(booster.sol) basic interface +interface IConvexBooster { + //deposit into convex, receive a tokenized deposit. parameter to stake immediately + function deposit( + uint256 _pid, + uint256 _amount, + bool _stake + ) external returns (bool); + + //burn a tokenized deposit to receive curve lp tokens back + function withdraw(uint256 _pid, uint256 _amount) external returns (bool); +} diff --git a/contracts/interfaces/external/ISwapRouter.sol b/contracts/interfaces/external/ISwapRouter.sol new file mode 100644 index 00000000..534d4f7e --- /dev/null +++ b/contracts/interfaces/external/ISwapRouter.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; +pragma abicoder v2; + +/// @title Router token swapping functionality +/// @notice Functions for swapping tokens via Uniswap V3 +interface ISwapRouter { + 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); +} diff --git a/test/convexAssetProxyTest.ts b/test/convexAssetProxyTest.ts new file mode 100644 index 00000000..85c9c561 --- /dev/null +++ b/test/convexAssetProxyTest.ts @@ -0,0 +1,286 @@ +import { SwapHelperStruct } from "typechain/ConvexAssetProxy"; +import { expect } from "chai"; +import { BigNumber, Signer } from "ethers"; +import { ethers, waffle, network } from "hardhat"; + +import { ConvexFixtureInterface, loadConvexFixture } from "./helpers/deployer"; +import { createSnapshot, restoreSnapshot } from "./helpers/snapshots"; + +import { impersonate, stopImpersonating } from "./helpers/impersonate"; +import { subError } from "./helpers/math"; +import { advanceBlock } from "./helpers/time"; + +const { provider } = waffle; + +describe("Convex Asset Proxy", () => { + let users: { user: Signer; address: string }[]; + let fixture: ConvexFixtureInterface; + // address of a large usdc holder to impersonate. 69 million usdc as of block 11860000 + const usdcWhaleAddress = "0xAe2D4617c862309A3d75A0fFB358c7a5009c673F"; + let user0LPStartingBalance: BigNumber; + let user1LPStartingBalance: BigNumber; + const alchemy_key = "kwjMP-X-Vajdk1ItCfU-56Uaq1wwhamK"; + + before(async () => { + // snapshot initial state + await createSnapshot(provider); + + // We need to fast forward in time relative to the previous pinned block number + // The LUSD-3CRV pool was deployed at block 12184843, and our hardhat config currently + // pins block 11853372 + await network.provider.request({ + method: "hardhat_reset", + params: [ + { + forking: { + jsonRpcUrl: `https://eth-mainnet.alchemyapi.io/v2/${alchemy_key}`, + // block at Mar-23-2022 08:34:43 AM +UTC + blockNumber: 14441489, + }, + }, + ], + }); + + const signers = await ethers.getSigners(); + // load all related contracts + fixture = await loadConvexFixture(signers[0]); + + // begin to populate the user array by assigning each index a signer + users = signers.map(function (user) { + return { user, address: "" }; + }); + + // finish populating the user array by assigning each index a signer address + await Promise.all( + users.map(async (userInfo) => { + const { user } = userInfo; + userInfo.address = await user.getAddress(); + }) + ); + + impersonate(usdcWhaleAddress); + const usdcWhale = await ethers.provider.getSigner(usdcWhaleAddress); + + await fixture.usdc.connect(usdcWhale).transfer(users[0].address, 2e11); // 200k usdc + await fixture.usdc.connect(usdcWhale).transfer(users[1].address, 2e11); // 200k usdc + await fixture.usdc.connect(usdcWhale).transfer(users[2].address, 2e11); // 200k usdc + + stopImpersonating(usdcWhaleAddress); + + // Let's deposit into Curve Pool to get LUSD-3CRV LP tokens back + await fixture.usdc + .connect(users[0].user) + .approve(fixture.curveZap.address, 10e11); + await fixture.usdc + .connect(users[1].user) + .approve(fixture.curveZap.address, 10e11); + await fixture.curveZap + .connect(users[0].user) + .add_liquidity(fixture.curveMetaPool, [0, 0, 2e11, 0], 0); + await fixture.curveZap + .connect(users[1].user) + .add_liquidity(fixture.curveMetaPool, [0, 0, 2e11, 0], 0); + + user0LPStartingBalance = await fixture.lpToken.balanceOf(users[0].address); + user1LPStartingBalance = await fixture.lpToken.balanceOf(users[1].address); + + // Approve the wrapped position to access our LP tokens + await fixture.lpToken + .connect(users[0].user) + .approve(fixture.position.address, ethers.constants.MaxUint256); + await fixture.lpToken + .connect(users[1].user) + .approve(fixture.position.address, ethers.constants.MaxUint256); + }); + + // After we reset our state in the fork + after(async () => { + await restoreSnapshot(provider); + + // After running all of these tests, reset back to the original pinned block + await network.provider.request({ + method: "hardhat_reset", + params: [ + { + forking: { + jsonRpcUrl: `https://eth-mainnet.alchemyapi.io/v2/${alchemy_key}`, + blockNumber: 11853372, + }, + }, + ], + }); + }); + + // Before each we snapshot + beforeEach(async () => { + await createSnapshot(provider); + }); + // After we reset our state in the fork + afterEach(async () => { + await restoreSnapshot(provider); + }); + + describe("deposit", () => { + beforeEach(async () => { + await createSnapshot(provider); + }); + // After we reset our state in the fork + afterEach(async () => { + await restoreSnapshot(provider); + }); + + it("deposits correctly", async () => { + await fixture.position + .connect(users[0].user) + .deposit(users[0].address, user0LPStartingBalance); + const balance = await fixture.position.balanceOf(users[0].address); + // Allows a 0.01% conversion error + expect(balance).to.be.at.least( + subError(ethers.BigNumber.from(user0LPStartingBalance)) + ); + }); + + it("Allows for two deposits correctly", async () => { + const minimumBalanceLpTokens = user0LPStartingBalance.gt( + user1LPStartingBalance + ) + ? user1LPStartingBalance + : user0LPStartingBalance; + + await fixture.position + .connect(users[0].user) + .deposit(users[0].address, minimumBalanceLpTokens); + await fixture.position + .connect(users[1].user) + .deposit(users[1].address, minimumBalanceLpTokens); + const user0Balance = await fixture.position.balanceOf(users[0].address); + const user1Balance = await fixture.position.balanceOf(users[1].address); + + // They are the only two depositors and deposit same amount, so they should have same shares + expect(user0Balance).to.be.eq(user1Balance); + }); + + it("fails to deposit amount greater than available", async () => { + const tx = fixture.position + .connect(users[1].user) + .deposit( + users[1].address, + user1LPStartingBalance.add(ethers.constants.WeiPerEther) + ); + await expect(tx).to.be.reverted; + }); + }); + describe("withdraw", () => { + it("withdraws correctly", async () => { + await fixture.position + .connect(users[0].user) + .deposit(users[0].address, user0LPStartingBalance); + const shareBalance = await fixture.position.balanceOf(users[0].address); + await fixture.position + .connect(users[0].user) + .withdraw(users[0].address, shareBalance, 0); + expect(await fixture.position.balanceOf(users[0].address)).to.equal(0); + // Should get all their LP tokens back + expect(await fixture.lpToken.balanceOf(users[0].address)).to.be.eq( + user0LPStartingBalance + ); + }); + it("fails to withdraw more shares than in balance", async () => { + // withdraw 10 shares from user with balance 0 + const tx = fixture.position + .connect(users[4].user) + .withdraw(users[4].address, 10, 0); + await expect(tx).to.be.reverted; + }); + // test withdrawUnderlying to verify _underlying calculation + it("withdrawUnderlying correctly", async () => { + await fixture.position + .connect(users[0].user) + .deposit(users[0].address, user0LPStartingBalance); + const shareBalance = await fixture.position.balanceOf(users[0].address); + // Withdraw to get 1/2 LP tokens sent to user2 + await fixture.position + .connect(users[0].user) + .withdrawUnderlying(users[2].address, shareBalance.div(2), 0); + expect(await fixture.position.balanceOf(users[2].address)).to.equal(0); + expect(await fixture.lpToken.balanceOf(users[2].address)).to.equal( + shareBalance.div(2) + ); + }); + }); + describe("rewards", () => { + it("Harvests rewards correctly", async () => { + await fixture.position + .connect(users[0].user) + .deposit(users[0].address, user0LPStartingBalance); + + // Now simulate passage of time to accrue CRV, CVX rewards + const blocks_per_day = 5760; + const days_to_simulate = 3; + for (let i = 0; i < blocks_per_day * days_to_simulate; i++) { + await advanceBlock(provider); + } + + // Now let the owner (user[0]) approve user 4 as an authorized harvester + await fixture.position.connect(users[0].user).authorize(users[4].address); + + // Now check deposited token balance before & after for our wrapped position + // Also check balance of LP token for harvester to ensure they received a bounty + const stakedRewardTokenBalanceBefore = + await fixture.rewardsContract.balanceOf(fixture.position.address); + const harvesterLpTokenBalanceBefore = await fixture.lpToken.balanceOf( + users[4].address + ); + + // Now trigger a harvest + // First, create our struct helpers for crv, cvx + const crvHelper: SwapHelperStruct = { + token: fixture.crv.address, + deadline: ethers.constants.MaxUint256, + amountOutMinimum: 0, + }; + const cvxHelper: SwapHelperStruct = { + token: fixture.cvx.address, + deadline: ethers.constants.MaxUint256, + amountOutMinimum: 0, + }; + + await fixture.position + .connect(users[4].user) + .harvest([crvHelper, cvxHelper]); + + // Now we should have more convexDeposit Token + const stakedRewardTokenBalanceAfter = + await fixture.rewardsContract.balanceOf(fixture.position.address); + expect(stakedRewardTokenBalanceAfter).to.be.gt( + stakedRewardTokenBalanceBefore + ); + + // Harvester should have received a bounty + const harvesterLpTokenBalanceAfter = await fixture.lpToken.balanceOf( + users[4].address + ); + expect(harvesterLpTokenBalanceAfter).to.be.gt( + harvesterLpTokenBalanceBefore + ); + }); + + it("fails for unauthorized user", async () => { + const crvHelper: SwapHelperStruct = { + token: fixture.crv.address, + deadline: ethers.constants.MaxUint256, + amountOutMinimum: 0, + }; + const cvxHelper: SwapHelperStruct = { + token: fixture.cvx.address, + deadline: ethers.constants.MaxUint256, + amountOutMinimum: 0, + }; + + const tx = fixture.position + .connect(users[4].user) + .harvest([crvHelper, cvxHelper]); + await expect(tx).to.be.reverted; + }); + }); +}); diff --git a/test/helpers/deployer.ts b/test/helpers/deployer.ts index e62a1bdd..cd418a68 100644 --- a/test/helpers/deployer.ts +++ b/test/helpers/deployer.ts @@ -1,5 +1,5 @@ -import { Signer } from "ethers"; import { ethers } from "hardhat"; +import { BigNumberish, Signer } from "ethers"; import "module-alias/register"; import { DateString__factory } from "typechain/factories/DateString__factory"; import { IERC20__factory } from "typechain/factories/IERC20__factory"; @@ -33,6 +33,19 @@ import data from "../../artifacts/contracts/Tranche.sol/Tranche.json"; import { CompoundAssetProxy__factory } from "typechain/factories/CompoundAssetProxy__factory"; import { CTokenInterface__factory } from "typechain/factories/CTokenInterface__factory"; import { CompoundAssetProxy } from "typechain/CompoundAssetProxy"; +import { + ConvexAssetProxy, + ConstructorParamsStruct, +} from "typechain/ConvexAssetProxy"; +import { IConvexBooster } from "typechain/IConvexBooster"; +import { IConvexBaseRewardPool } from "typechain/IConvexBaseRewardPool"; +import { ISwapRouter } from "typechain/ISwapRouter"; +import { I3CurvePoolDepositZap } from "typechain/I3CurvePoolDepositZap"; +import { IConvexBooster__factory } from "typechain/factories/IConvexBooster__factory"; +import { IConvexBaseRewardPool__factory } from "typechain/factories/IConvexBaseRewardPool__factory"; +import { I3CurvePoolDepositZap__factory } from "./../../typechain/factories/I3CurvePoolDepositZap__factory"; +import { ConvexAssetProxy__factory } from "typechain/factories/ConvexAssetProxy__factory"; +import { ISwapRouter__factory } from "typechain/factories/ISwapRouter__factory"; import { CTokenInterface } from "typechain/CTokenInterface"; export interface FixtureInterface { @@ -55,6 +68,22 @@ export interface CFixtureInterface { proxy: TestUserProxy; } +export interface ConvexFixtureInterface { + signer: Signer; + position: ConvexAssetProxy; + booster: IConvexBooster; + rewardsContract: IConvexBaseRewardPool; + curveZap: I3CurvePoolDepositZap; + curveMetaPool: string; + convexDepositToken: IERC20; + lpToken: IERC20; + router: ISwapRouter; + usdc: IERC20; + crv: IERC20; + cvx: IERC20; + proxy: TestUserProxy; +} + export interface EthPoolMainnetInterface { signer: Signer; weth: IWETH; @@ -171,6 +200,47 @@ const deployCasset = async ( ); }; +const deployConvexAssetProxy = async ( + signer: Signer, + curveZap: string, + curveMetaPool: string, + booster: string, + rewardsContract: string, + convexDepositToken: string, + router: string, + pid: BigNumberish, + keeperFee: BigNumberish, + crvSwapPath: string, + cvxSwapPath: string, + token: string, + name: string, + symbol: string, + governance: string, + pauser: string +) => { + const convexDeployer = new ConvexAssetProxy__factory(signer); + const constructorParams: ConstructorParamsStruct = { + curveZap: curveZap, + curveMetaPool: curveMetaPool, + booster: booster, + rewardsContract: rewardsContract, + convexDepositToken: convexDepositToken, + router: router, + pid: pid, + keeperFee: keeperFee, + }; + return await convexDeployer.deploy( + constructorParams, + crvSwapPath, + cvxSwapPath, + token, + name, + symbol, + governance, + pauser + ); +}; + const deployInterestTokenFactory = async (signer: Signer) => { const deployer = new InterestTokenFactory__factory(signer); return await deployer.deploy(); @@ -303,6 +373,120 @@ export async function loadCFixture(signer: Signer) { return { signer, position, cusdc, usdc, comp, proxy }; } +export async function loadConvexFixture( + signer: Signer +): Promise { + // Some addresses specific to LUSD3CRV pool + const wethAddress = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; + const owner = signer; + const usdcAddress = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"; + const crvAddress = "0xD533a949740bb3306d119CC777fa900bA034cd52"; + const cvxAddress = "0x4e3FBD56CD56c3e72c1403e103b45Db9da5B9D2B"; + const boosterAddress = "0xF403C135812408BFbE8713b5A23a04b3D48AAE31"; + const rewardsContractAddress = "0x2ad92A7aE036a038ff02B96c88de868ddf3f8190"; + const pool3CrvDepositZapAddress = + "0xA79828DF1850E8a3A3064576f380D90aECDD3359"; + // Metapool for LUSD3CRV, also the LP token address + const curveMetaPool = "0xEd279fDD11cA84bEef15AF5D39BB4d4bEE23F0cA"; + const cvxLusd3CRV = "0xFB9B2f06FDb404Fd3E2278E9A9edc8f252F273d0"; + // Uniswap V3 router address + const routerAddress = "0xE592427A0AEce92De3Edee1F18E0157C05861564"; + // Pool ID for LUSD-3CRV pool + const pid = 33; + // Keeper fee is 5% + const keeperFee = 50; + // multi-hops are [TokenA, fee, TokenB, fee, TokenC, ... TokenOut] + // Jump from CRV to WETH to USDC + // Note: 10000 = 1% pool fee + const crvSwapPath = ethers.utils.solidityPack( + ["address", "uint24", "address", "uint24", "address"], + [crvAddress, 10000, wethAddress, 500, usdcAddress] + ); + const cvxSwapPath = ethers.utils.solidityPack( + ["address", "uint24", "address", "uint24", "address"], + [cvxAddress, 10000, wethAddress, 500, usdcAddress] + ); + + const usdc = IERC20__factory.connect(usdcAddress, owner); + const crv = IERC20__factory.connect(crvAddress, owner); + const cvx = IERC20__factory.connect(cvxAddress, owner); + const booster = IConvexBooster__factory.connect(boosterAddress, owner); + const rewardsContract = IConvexBaseRewardPool__factory.connect( + rewardsContractAddress, + owner + ); + const curveZap = I3CurvePoolDepositZap__factory.connect( + pool3CrvDepositZapAddress, + owner + ); + const convexDepositToken = IERC20__factory.connect(cvxLusd3CRV, owner); + const lpToken = IERC20__factory.connect(curveMetaPool, owner); + const router = ISwapRouter__factory.connect(routerAddress, owner); + + const ownerAddress = await signer.getAddress(); + + const position: ConvexAssetProxy = await deployConvexAssetProxy( + owner, + pool3CrvDepositZapAddress, + curveMetaPool, + boosterAddress, + rewardsContractAddress, + cvxLusd3CRV, + routerAddress, + pid, + keeperFee, + crvSwapPath, + cvxSwapPath, + curveMetaPool, + "proxyLusd3CRV", + "epLusd3Crv", + ownerAddress, + ownerAddress + ); + + // deploy and fetch tranche contract + const trancheFactory = await deployTrancheFactory(owner); + await trancheFactory.deployTranche(1e10, position.address); + const eventFilter = trancheFactory.filters.TrancheCreated(null, null, null); + const events = await trancheFactory.queryFilter(eventFilter); + const trancheAddress = events[0] && events[0].args && events[0].args[0]; + const tranche = Tranche__factory.connect(trancheAddress, owner); + + const interestTokenAddress = await tranche.interestToken(); + const interestToken = InterestToken__factory.connect( + interestTokenAddress, + owner + ); + + // Setup the proxy + const bytecodehash = ethers.utils.solidityKeccak256( + ["bytes"], + [data.bytecode] + ); + const proxyFactory = new TestUserProxy__factory(signer); + const proxy = await proxyFactory.deploy( + wethAddress, + trancheFactory.address, + bytecodehash + ); + + return { + signer, + position, + booster, + rewardsContract, + curveZap, + curveMetaPool, + convexDepositToken, + lpToken, + router, + usdc, + crv, + cvx, + proxy, + }; +} + export async function loadEthPoolMainnetFixture() { const wethAddress = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; const ywethAddress = "0xac333895ce1A73875CF7B4Ecdc5A743C12f3d82B";