diff --git a/contracts/NestedFactory.sol b/contracts/NestedFactory.sol index f46f2fd4..90cd8be1 100644 --- a/contracts/NestedFactory.sol +++ b/contracts/NestedFactory.sol @@ -15,8 +15,6 @@ import "./NestedAsset.sol"; import "./NestedRecords.sol"; import "./Withdrawer.sol"; -import "hardhat/console.sol"; - /// @title Creates, updates and destroys NestedAssets (portfolios). /// @notice Responsible for the business logic of the protocol and interaction with operators contract NestedFactory is INestedFactory, ReentrancyGuard, OwnableProxyDelegation, MixinOperatorResolver { @@ -83,9 +81,14 @@ contract NestedFactory is INestedFactory, ReentrancyGuard, OwnableProxyDelegatio withdrawer = _withdrawer; } - /// @dev Receive function + /// @dev Receive function that will wrap the ether if + /// an address other than the withdrawer sends ether to + /// to the contract. The factory cannot handle ether but + /// has functions to withdraw ERC20 tokens if needed. receive() external payable { - require(msg.sender == address(withdrawer), "NF: ETH_SENDER_NOT_WITHDRAWER"); + if (msg.sender != address(withdrawer)) { + weth.deposit{ value: msg.value }(); + } } /* ------------------------------ MODIFIERS ---------------------------- */ diff --git a/contracts/interfaces/external/ICurvePool/ICurvePool.sol b/contracts/interfaces/external/ICurvePool/ICurvePool.sol new file mode 100644 index 00000000..ae0c7761 --- /dev/null +++ b/contracts/interfaces/external/ICurvePool/ICurvePool.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.11; + +/// @title Curve pool interface +interface ICurvePool { + function token() external view returns (address); + + function coins(uint256 index) external view returns (address); +} diff --git a/contracts/interfaces/external/ICurvePool/ICurvePoolETH.sol b/contracts/interfaces/external/ICurvePool/ICurvePoolETH.sol new file mode 100644 index 00000000..cb700cc6 --- /dev/null +++ b/contracts/interfaces/external/ICurvePool/ICurvePoolETH.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.11; + +import "./ICurvePool.sol"; + +/// @title ETH Curve pool interface +/// @notice The difference with non-ETH pools is that ETH pools must have +/// a payable add_liquidity function to allow direct sending of +/// ETH in order to add liquidity with ETH and not an ERC20. +interface ICurvePoolETH is ICurvePool { + function add_liquidity(uint256[2] calldata amounts, uint256 min_mint_amount) external payable; + + function add_liquidity(uint256[3] calldata amounts, uint256 min_mint_amount) external payable; + + function add_liquidity(uint256[4] calldata amounts, uint256 min_mint_amount) external payable; +} diff --git a/contracts/interfaces/external/ICurvePool/ICurvePoolNonETH.sol b/contracts/interfaces/external/ICurvePool/ICurvePoolNonETH.sol new file mode 100644 index 00000000..2c70e712 --- /dev/null +++ b/contracts/interfaces/external/ICurvePool/ICurvePoolNonETH.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.11; + +import "./ICurvePool.sol"; + +/// @title non-ETH Curve pool interface +/// @notice The difference with ETH pools is that ETH pools must have +/// a payable add_liquidity function to allow direct sending of +/// ETH in order to add liquidity with ETH and not an ERC20. +interface ICurvePoolNonETH is ICurvePool { + function add_liquidity(uint256[2] calldata amounts, uint256 min_mint_amount) external; + + function add_liquidity(uint256[3] calldata amounts, uint256 min_mint_amount) external; + + function add_liquidity(uint256[4] calldata amounts, uint256 min_mint_amount) external; +} diff --git a/contracts/interfaces/external/IStakingVault/IStakeDaoStrategy.sol b/contracts/interfaces/external/IStakingVault/IStakeDaoStrategy.sol new file mode 100644 index 00000000..c715f5da --- /dev/null +++ b/contracts/interfaces/external/IStakingVault/IStakeDaoStrategy.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.11; + +import "./IStakingVault.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/// @title StakeDAO strategy interface +/// @dev In the deployed code of StakeDAO, the token() function +/// allows to retrieve the LP token to stake. +/// Note : In the StakeDAO repository, this function has +/// been replaced by want(). +interface IStakeDaoStrategy is IStakingVault { + function token() external view returns (IERC20); +} diff --git a/contracts/interfaces/external/IStakingVault/IStakingVault.sol b/contracts/interfaces/external/IStakingVault/IStakingVault.sol new file mode 100644 index 00000000..c7f953d0 --- /dev/null +++ b/contracts/interfaces/external/IStakingVault/IStakingVault.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.11; + +/// @title Generic staking vault interface +interface IStakingVault { + function deposit(uint256 _amount) external; + + function withdraw(uint256 _shares) external; +} diff --git a/contracts/interfaces/external/IStakingVault/IYearnVault.sol b/contracts/interfaces/external/IStakingVault/IYearnVault.sol new file mode 100644 index 00000000..34ee13ad --- /dev/null +++ b/contracts/interfaces/external/IStakingVault/IYearnVault.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.11; + +import "./IStakingVault.sol"; + +/// @dev Yearn vault interface +interface IYearnVault is IStakingVault { + function withdraw( + uint256 _shares, + address _recipient, + uint256 _maxLoss + ) external returns (uint256); +} diff --git a/contracts/interfaces/external/IWETH.sol b/contracts/interfaces/external/IWETH.sol index e0226ba0..d054d7bd 100644 --- a/contracts/interfaces/external/IWETH.sol +++ b/contracts/interfaces/external/IWETH.sol @@ -11,6 +11,8 @@ interface IWETH { function transfer(address recipient, uint256 amount) external returns (bool); + function balanceOf(address recipien) external returns (uint256); + function allowance(address owner, address spender) external view returns (uint256); function approve(address spender, uint256 amount) external returns (bool); diff --git a/contracts/libraries/CurveHelpers.sol b/contracts/libraries/CurveHelpers.sol new file mode 100644 index 00000000..b1a065c7 --- /dev/null +++ b/contracts/libraries/CurveHelpers.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.11; + +import "./../interfaces/external/ICurvePool/ICurvePool.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +/// @notice Library for Curve deposit/withdraw +library CurveHelpers { + using SafeERC20 for IERC20; + + /// @dev Get the array of token amount to send to a + /// Curve 2pool to add liquidity + /// @param pool The curve 2pool + /// @param token The token to remove from the pool + /// @param amount The amount of token to remove from the pool + /// @return amounts Array of 2 token amounts sorted by Curve pool token indexes + function getAmounts2Coins( + ICurvePool pool, + address token, + uint256 amount + ) internal view returns (uint256[2] memory amounts) { + for (uint256 i; i < 2; i++) { + if (token == pool.coins(i)) { + amounts[i] = amount; + return amounts; + } + } + revert("CH: INVALID_INPUT_TOKEN"); + } + + /// @dev Get the array of token amount to send to a + /// Curve 3pool to add liquidity + /// @param pool The curve 3pool + /// @param token The token to remove from the pool + /// @param amount The amount of token to remove from the pool + /// @return amounts Array of 3 token amounts sorted by Curve pool token indexes + function getAmounts3Coins( + ICurvePool pool, + address token, + uint256 amount + ) internal view returns (uint256[3] memory amounts) { + for (uint256 i; i < 3; i++) { + if (token == pool.coins(i)) { + amounts[i] = amount; + return amounts; + } + } + revert("CH: INVALID_INPUT_TOKEN"); + } + + /// @dev Get the array of token amount to send to a + /// Curve 4pool to add liquidity + /// @param pool The curve 4pool + /// @param token The token to remove from the pool + /// @param amount The amount of token to remove from the pool + /// @return amounts Array of 4 token amounts sorted by Curve pool token indexes + function getAmounts4Coins( + ICurvePool pool, + address token, + uint256 amount + ) internal view returns (uint256[4] memory amounts) { + for (uint256 i; i < 4; i++) { + if (token == pool.coins(i)) { + amounts[i] = amount; + return amounts; + } + } + revert("CH: INVALID_INPUT_TOKEN"); + } + + /// @dev Remove liquidity from a Curve pool + /// @param pool The Curve pool to remove liquidity from + /// @param amount The Curve pool LP token to withdraw + /// @param outputToken One of the Curve pool token + /// @param poolCoinAmount The amount of token in the Curve pool + /// @param signature The signature of the remove_liquidity_one_coin + /// function to be used to call to the Curve pool + /// @return success If the call to remove liquidity succeeded + function removeLiquidityOneCoin( + ICurvePool pool, + uint256 amount, + address outputToken, + uint256 poolCoinAmount, + bytes4 signature + ) internal returns (bool success) { + for (uint256 i; i < poolCoinAmount; i++) { + if (outputToken == pool.coins(i)) { + (success, ) = address(pool).call(abi.encodeWithSelector(signature, amount, i, 0)); + return success; + } + } + revert("CH: INVALID_OUTPUT_TOKEN"); + } +} diff --git a/contracts/libraries/OperatorHelpers.sol b/contracts/libraries/OperatorHelpers.sol new file mode 100644 index 00000000..a4e203ee --- /dev/null +++ b/contracts/libraries/OperatorHelpers.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.11; + +import "./../interfaces/external/ICurvePool/ICurvePool.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +/// @notice Library for all operators +library OperatorHelpers { + using SafeERC20 for IERC20; + + /// @dev Get the arrays of obtained token and spent token + /// @param inputToken The token spent + /// @param inputTokenBalanceBefore The input token balance before + /// @param expectedInputAmount The expected amount of input token spent + /// @param outputToken The token obtained + /// @param outputTokenBalanceBefore The output token balance before + /// @param minAmountOut The minimum of output token expected + function getOutputAmounts( + IERC20 inputToken, + uint256 inputTokenBalanceBefore, + uint256 expectedInputAmount, + IERC20 outputToken, + uint256 outputTokenBalanceBefore, + uint256 minAmountOut + ) internal view returns (uint256[] memory amounts, address[] memory tokens) { + require( + inputTokenBalanceBefore - inputToken.balanceOf(address(this)) == expectedInputAmount, + "OH: INVALID_AMOUNT_WITHDRAWED" + ); + + uint256 tokenAmount = outputToken.balanceOf(address(this)) - outputTokenBalanceBefore; + require(tokenAmount != 0, "OH: INVALID_AMOUNT_RECEIVED"); + require(tokenAmount >= minAmountOut, "OH: INVALID_AMOUNT_RECEIVED"); + + amounts = new uint256[](2); + tokens = new address[](2); + + // Output amounts + amounts[0] = tokenAmount; + amounts[1] = expectedInputAmount; + + // Output token + tokens[0] = address(outputToken); + tokens[1] = address(inputToken); + } +} diff --git a/contracts/libraries/StakingLPVaultHelpers.sol b/contracts/libraries/StakingLPVaultHelpers.sol new file mode 100644 index 00000000..7056d2fd --- /dev/null +++ b/contracts/libraries/StakingLPVaultHelpers.sol @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.11; + +import "./../Withdrawer.sol"; +import "./../libraries/CurveHelpers.sol"; +import "./../libraries/ExchangeHelpers.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "./../interfaces/external/ICurvePool/ICurvePool.sol"; +import "./../interfaces/external/ICurvePool/ICurvePoolETH.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "./../interfaces/external/IStakingVault/IStakingVault.sol"; +import "./../interfaces/external/ICurvePool/ICurvePoolNonETH.sol"; + +/// @notice Library for LP Staking Vaults deposit/withdraw +library StakingLPVaultHelpers { + using SafeERC20 for IERC20; + + /// @dev Add liquidity in a Curve pool with ETH and deposit + /// the LP token in a staking vault + /// @param vault The staking vault address to deposit into + /// @param pool The Curve pool to add liquitiy in + /// @param lpToken The Curve pool LP token + /// @param poolCoinAmount The number of token in the Curve pool + /// @param eth ETH address + /// @param amount ETH amount to add in the Curve pool + function _addLiquidityAndDepositETH( + address vault, + ICurvePoolETH pool, + IERC20 lpToken, + uint256 poolCoinAmount, + address eth, + uint256 amount + ) internal { + uint256 lpTokenBalanceBefore = lpToken.balanceOf(address(this)); + + if (poolCoinAmount == 2) { + pool.add_liquidity{ value: amount }(CurveHelpers.getAmounts2Coins(pool, eth, amount), 0); + } else if (poolCoinAmount == 3) { + pool.add_liquidity{ value: amount }(CurveHelpers.getAmounts3Coins(pool, eth, amount), 0); + } else { + pool.add_liquidity{ value: amount }(CurveHelpers.getAmounts4Coins(pool, eth, amount), 0); + } + + uint256 lpTokenToDeposit = lpToken.balanceOf(address(this)) - lpTokenBalanceBefore; + ExchangeHelpers.setMaxAllowance(lpToken, vault); + IStakingVault(vault).deposit(lpTokenToDeposit); + } + + /// @dev Add liquidity in a Curve pool and deposit + /// the LP token in a staking vault + /// @param vault The staking vault address to deposit into + /// @param pool The Curve pool to add liquitiy in + /// @param lpToken The Curve pool lpToken + /// @param poolCoinAmount The number of token in the Curve pool + /// @param token Token to add in the Curve pool liquidity + /// @param amount Token amount to add in the Curve pool + function _addLiquidityAndDeposit( + address vault, + ICurvePoolNonETH pool, + IERC20 lpToken, + uint256 poolCoinAmount, + address token, + uint256 amount + ) internal { + uint256 lpTokenBalanceBefore = lpToken.balanceOf(address(this)); + ExchangeHelpers.setMaxAllowance(IERC20(token), address(pool)); + + if (poolCoinAmount == 2) { + pool.add_liquidity(CurveHelpers.getAmounts2Coins(pool, token, amount), 0); + } else if (poolCoinAmount == 3) { + pool.add_liquidity(CurveHelpers.getAmounts3Coins(pool, token, amount), 0); + } else { + pool.add_liquidity(CurveHelpers.getAmounts4Coins(pool, token, amount), 0); + } + + uint256 lpTokenToDeposit = lpToken.balanceOf(address(this)) - lpTokenBalanceBefore; + ExchangeHelpers.setMaxAllowance(lpToken, vault); + IStakingVault(vault).deposit(lpTokenToDeposit); + } + + /// @dev Withdraw the LP token from the staking vault and + /// remove the liquidity from the Curve pool + /// @param vault The staking vault address to withdraw from + /// @param amount The amount to withdraw + /// @param pool The Curve pool to remove liquitiy from + /// @param lpToken The Curve pool LP token + /// @param poolCoinAmount The number of token in the Curve pool + /// @param outputToken Output token to receive + function _withdrawAndRemoveLiquidity128( + address vault, + uint256 amount, + ICurvePool pool, + IERC20 lpToken, + uint256 poolCoinAmount, + address outputToken + ) internal { + uint256 lpTokenBalanceBefore = lpToken.balanceOf(address(this)); + IStakingVault(vault).withdraw(amount); + + bool success = CurveHelpers.removeLiquidityOneCoin( + pool, + lpToken.balanceOf(address(this)) - lpTokenBalanceBefore, + outputToken, + poolCoinAmount, + bytes4(keccak256(bytes("remove_liquidity_one_coin(uint256,int128,uint256)"))) + ); + + require(success, "SDCSO: CURVE_RM_LIQUIDITY_FAILED"); + } + + /// @dev Withdraw the LP token from the staking vault and + /// remove the liquidity from the Curve pool + /// @param vault The staking vault address to withdraw from + /// @param amount The amount to withdraw + /// @param pool The Curve pool to remove liquitiy from + /// @param lpToken The Curve pool LP token + /// @param poolCoinAmount The number of token in the Curve pool + /// @param outputToken Output token to receive + function _withdrawAndRemoveLiquidity256( + address vault, + uint256 amount, + ICurvePool pool, + IERC20 lpToken, + uint256 poolCoinAmount, + address outputToken + ) internal { + uint256 lpTokenBalanceBefore = lpToken.balanceOf(address(this)); + IStakingVault(vault).withdraw(amount); + + bool success = CurveHelpers.removeLiquidityOneCoin( + pool, + lpToken.balanceOf(address(this)) - lpTokenBalanceBefore, + outputToken, + poolCoinAmount, + bytes4(keccak256(bytes("remove_liquidity_one_coin(uint256,uint256,uint256)"))) + ); + + require(success, "SDCSO: CURVE_RM_LIQUIDITY_FAILED"); + } +} diff --git a/contracts/operators/StakeDAO/StakeDaoCurveStrategyOperator.sol b/contracts/operators/StakeDAO/StakeDaoCurveStrategyOperator.sol new file mode 100644 index 00000000..2c3fddc1 --- /dev/null +++ b/contracts/operators/StakeDAO/StakeDaoCurveStrategyOperator.sol @@ -0,0 +1,286 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.11; + +import "./../../Withdrawer.sol"; +import "./StakeDaoStrategyStorage.sol"; +import "./../../libraries/CurveHelpers.sol"; +import "./../../interfaces/external/IWETH.sol"; +import "./../../libraries/OperatorHelpers.sol"; +import "./../../libraries/ExchangeHelpers.sol"; +import "./../../libraries/StakingLPVaultHelpers.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "./../../interfaces/external/ICurvePool/ICurvePool.sol"; +import "./../../interfaces/external/ICurvePool/ICurvePoolETH.sol"; +import "./../../interfaces/external/ICurvePool/ICurvePoolNonETH.sol"; +import "./../../interfaces/external/IStakingVault/IStakeDaoStrategy.sol"; + +/// @title StakeDAO Curve strategy operator +/// @notice Deposit/Withdraw in a StakeDAO strategy +contract StakeDaoCurveStrategyOperator { + StakeDaoStrategyStorage public immutable operatorStorage; + + /// @dev ETH address + address public immutable eth; + + /// @dev WETH contract + IWETH private immutable weth; + + /// @dev Withdrawer + Withdrawer private immutable withdrawer; + + constructor( + address[] memory strategies, + CurvePool[] memory pools, + Withdrawer _withdrawer, + address _eth, + address _weth + ) { + uint256 strategiesLength = strategies.length; + require(strategiesLength == pools.length, "SDCSO: INVALID_POOLS_LENGTH"); + operatorStorage = new StakeDaoStrategyStorage(); + + for (uint256 i; i < strategiesLength; i++) { + operatorStorage.addStrategy(strategies[i], pools[i]); + } + + operatorStorage.transferOwnership(msg.sender); + + eth = _eth; + weth = IWETH(_weth); + withdrawer = _withdrawer; + } + + /// @notice Add liquidity in a Curve pool that includes ETH, + /// deposit the LP token in a StakeDAO strategy and receive + /// the StakeDAO strategy token + /// @param strategy The StakeDAO strategy address to deposit into + /// @param amount The amount of token to add liquidity + /// @param minStrategyAmount The minimum of StakeDAO strategy token expected + /// @return amounts Array of amounts : + /// - [0] : The strategy token received amount + /// - [1] : The token deposited amount + /// @return tokens Array of token addresses + /// - [0] : The strategy token received address + /// - [1] : The token deposited address + function depositETH( + address strategy, + uint256 amount, + uint256 minStrategyAmount + ) public payable returns (uint256[] memory amounts, address[] memory tokens) { + require(amount != 0, "SDCSO: INVALID_AMOUNT"); + + (address pool, uint96 poolCoinAmount, address lpToken) = operatorStorage.strategies(strategy); + require(pool != address(0), "SDCSO: INVALID_STRATEGY"); + + uint256 strategyBalanceBefore = IERC20(strategy).balanceOf(address(this)); + uint256 ethBalanceBefore = weth.balanceOf(address(this)); + + ExchangeHelpers.setMaxAllowance(IERC20(address(weth)), address(withdrawer)); + + // withdraw ETH from WETH + withdrawer.withdraw(amount); + + StakingLPVaultHelpers._addLiquidityAndDepositETH( + strategy, + ICurvePoolETH(pool), + IERC20(lpToken), + poolCoinAmount, + eth, + amount + ); + + (amounts, tokens) = OperatorHelpers.getOutputAmounts( + IERC20(address(weth)), + ethBalanceBefore, + amount, + IERC20(strategy), + strategyBalanceBefore, + minStrategyAmount + ); + } + + /// @notice Add liquidity to a Curve pool using the input token, + /// deposit the LP token in a StakeDAO strategy and receive + /// the strategy token + /// @param strategy The StakeDAO strategy address in wich to deposit the LP token + /// @param token The input token to use for adding liquidity + /// @param amount The input token amount to use for adding liquidity + /// @param minStrategyToken The minimum strategy token expected + /// @return amounts Array of amounts : + /// - [0] : The received strategy token amount + /// - [1] : The deposited token amount + /// @return tokens Array of token addresses + /// - [0] : The received strategy token address + /// - [1] : The deposited token address + function deposit( + address strategy, + address token, + uint256 amount, + uint256 minStrategyToken + ) external payable returns (uint256[] memory amounts, address[] memory tokens) { + require(amount != 0, "SDCSO: INVALID_AMOUNT"); + (address pool, uint96 poolCoinAmount, address lpToken) = operatorStorage.strategies(strategy); + require(pool != address(0), "SDCSO: INVALID_STRATEGY"); + + uint256 strategyBalanceBefore = IERC20(strategy).balanceOf(address(this)); + uint256 tokenBalanceBefore = IERC20(token).balanceOf(address(this)); + + StakingLPVaultHelpers._addLiquidityAndDeposit( + strategy, + ICurvePoolNonETH(pool), + IERC20(lpToken), + poolCoinAmount, + token, + amount + ); + + (amounts, tokens) = OperatorHelpers.getOutputAmounts( + IERC20(token), + tokenBalanceBefore, + amount, + IERC20(strategy), + strategyBalanceBefore, + minStrategyToken + ); + } + + /// @notice Withdraw the LP token from the StakeDAO strategy, + /// remove ETH liquidity from the Curve pool + /// and receive one of the Curve pool token + /// @param strategy The StakeDAO strategy address to withdraw from + /// @param amount The amount to withdraw + /// @param minAmountOut The minimum of output token expected + /// @return amounts Array of amounts : + /// - [0] : The token received amount + /// - [1] : The strategy token deposited amount + /// @return tokens Array of token addresses + /// - [0] : The token received address + /// - [1] : The strategy token deposited address + function withdrawETH( + address strategy, + uint256 amount, + uint256 minAmountOut + ) external payable returns (uint256[] memory amounts, address[] memory tokens) { + require(amount != 0, "SDCSO: INVALID_AMOUNT"); + + (address pool, uint96 poolCoinAmount, address lpToken) = operatorStorage.strategies(strategy); + require(pool != address(0), "SDCSO: INVALID_STRATEGY"); + + uint256 strategyBalanceBefore = IERC20(strategy).balanceOf(address(this)); + uint256 tokenBalanceBefore = weth.balanceOf(address(this)); + + StakingLPVaultHelpers._withdrawAndRemoveLiquidity128( + strategy, + amount, + ICurvePool(pool), + IERC20(lpToken), + poolCoinAmount, + eth + ); + + (amounts, tokens) = OperatorHelpers.getOutputAmounts( + IERC20(strategy), + strategyBalanceBefore, + amount, + IERC20(address(weth)), + tokenBalanceBefore, + minAmountOut + ); + } + + /// @notice Withdraw the LP token from the StakeDAO strategy, + /// remove the liquidity from the Curve pool + /// (using int128 for the curvePool.remove_liquidity_one_coin + /// coin index parameter) and receive one of the + /// Curve pool token + /// @param strategy The StakeDAO strategy address to withdraw from + /// @param amount The amount to withdraw + /// @param outputToken Output token to receive + /// @param minAmountOut The minimum of output token expected + /// @return amounts Array of amounts : + /// - [0] : The token received amount + /// - [1] : The strategy token deposited amount + /// @return tokens Array of token addresses + /// - [0] : The token received address + /// - [1] : The strategy token deposited address + function withdraw128( + address strategy, + uint256 amount, + IERC20 outputToken, + uint256 minAmountOut + ) external payable returns (uint256[] memory amounts, address[] memory tokens) { + require(amount != 0, "SDCSO: INVALID_AMOUNT"); + + (address pool, uint96 poolCoinAmount, address lpToken) = operatorStorage.strategies(strategy); + require(pool != address(0), "SDCSO: INVALID_STRATEGY"); + + uint256 strategyBalanceBefore = IERC20(strategy).balanceOf(address(this)); + uint256 tokenBalanceBefore = outputToken.balanceOf(address(this)); + + StakingLPVaultHelpers._withdrawAndRemoveLiquidity128( + strategy, + amount, + ICurvePool(pool), + IERC20(lpToken), + poolCoinAmount, + address(outputToken) + ); + + (amounts, tokens) = OperatorHelpers.getOutputAmounts( + IERC20(strategy), + strategyBalanceBefore, + amount, + outputToken, + tokenBalanceBefore, + minAmountOut + ); + } + + /// @notice Withdraw the LP token from the StakeDAO strategy, + /// remove the liquidity from the Curve pool + /// (using uint256 for the curvePool.remove_liquidity_one_coin + /// coin index parameter) and receive one of the + /// Curve pool token + /// @param strategy The StakeDAO strategy address to withdraw from + /// @param amount The amount to withdraw + /// @param outputToken Output token to receive + /// @param minAmountOut The minimum of output token expected + /// @return amounts Array of amounts : + /// - [0] : The token received amount + /// - [1] : The strategy token deposited amount + /// @return tokens Array of token addresses + /// - [0] : The token received address + /// - [1] : The strategy token deposited address + function withdraw256( + address strategy, + uint256 amount, + IERC20 outputToken, + uint256 minAmountOut + ) external payable returns (uint256[] memory amounts, address[] memory tokens) { + require(amount != 0, "SDCSO: INVALID_AMOUNT"); + + (address pool, uint96 poolCoinAmount, address lpToken) = operatorStorage.strategies(strategy); + require(pool != address(0), "SDCSO: INVALID_STRATEGY"); + + uint256 strategyBalanceBefore = IERC20(strategy).balanceOf(address(this)); + uint256 tokenBalanceBefore = outputToken.balanceOf(address(this)); + + StakingLPVaultHelpers._withdrawAndRemoveLiquidity256( + strategy, + amount, + ICurvePoolNonETH(pool), + IERC20(lpToken), + poolCoinAmount, + address(outputToken) + ); + + (amounts, tokens) = OperatorHelpers.getOutputAmounts( + IERC20(strategy), + strategyBalanceBefore, + amount, + outputToken, + tokenBalanceBefore, + minAmountOut + ); + } +} diff --git a/contracts/operators/StakeDAO/StakeDaoStrategyStorage.sol b/contracts/operators/StakeDAO/StakeDaoStrategyStorage.sol new file mode 100644 index 00000000..5e3a0e93 --- /dev/null +++ b/contracts/operators/StakeDAO/StakeDaoStrategyStorage.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.11; + +import "@openzeppelin/contracts/access/Ownable.sol"; + +/// @dev A Curve pool with its number of coins +/// @param poolAddress The address of the curve pool +/// @param poolCoinAmount The number of coins inside +/// @param lpToken The corresponding pool LP token address +struct CurvePool { + address poolAddress; + uint96 poolCoinAmount; + address lpToken; +} + +/// @title StakeDAO strategy operator's storage contract +contract StakeDaoStrategyStorage is Ownable { + /// @dev Emitted when a strategy is added + /// @param strategy The strategy address + /// @param pool The underlying CurvePool + event StrategyAdded(address strategy, CurvePool pool); + + /// @dev Emitted when a strategy is removed + /// @param strategy The removed strategy address + event StrategyRemoved(address strategy); + + /// @dev Map of strategy address with underlying CurvePool + mapping(address => CurvePool) public strategies; + + /// @notice Add a StakeDAO strategy + /// @param strategy The strategy address + /// @param curvePool The underlying CurvePool (used to deposit) + function addStrategy(address strategy, CurvePool calldata curvePool) external onlyOwner { + require(strategy != address(0), "SDSS: INVALID_STRATEGY_ADDRESS"); + require(curvePool.poolAddress != address(0), "SDSS: INVALID_POOL_ADDRESS"); + require(curvePool.lpToken != address(0), "SDSS: INVALID_TOKEN_ADDRESS"); + require(strategies[strategy].poolAddress == address(0), "SDSS: STRATEGY_ALREADY_HAS_POOL"); + require(strategies[strategy].lpToken == address(0), "SDSS: STRATEGY_ALREADY_HAS_LP"); + strategies[strategy] = curvePool; + emit StrategyAdded(strategy, curvePool); + } + + /// @notice Remove a StakeDAO strategy + /// @param strategy The strategy address to remove + function removeStrategy(address strategy) external onlyOwner { + require(strategies[strategy].poolAddress != address(0), "SDSS: NON_EXISTENT_STRATEGY"); + delete strategies[strategy]; + emit StrategyRemoved(strategy); + } +} diff --git a/contracts/operators/Yearn/YearnCurveVaultOperator.sol b/contracts/operators/Yearn/YearnCurveVaultOperator.sol new file mode 100644 index 00000000..42407d57 --- /dev/null +++ b/contracts/operators/Yearn/YearnCurveVaultOperator.sol @@ -0,0 +1,285 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.11; + +import "./../../Withdrawer.sol"; +import "./YearnVaultStorage.sol"; +import "./../../libraries/CurveHelpers.sol"; +import "./../../libraries/OperatorHelpers.sol"; +import "./../../libraries/ExchangeHelpers.sol"; +import "./../../interfaces/external/IWETH.sol"; +import "../../libraries/StakingLPVaultHelpers.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "./../../interfaces/external/ICurvePool/ICurvePoolETH.sol"; +import "./../../interfaces/external/IStakingVault/IYearnVault.sol"; +import "./../../interfaces/external/ICurvePool/ICurvePoolNonETH.sol"; + +/// @title Yearn Curve Vault Operator +/// @notice Deposit/Withdraw in a Yearn Curve vault. +contract YearnCurveVaultOperator { + YearnVaultStorage public immutable operatorStorage; + + /// @dev ETH address + address public immutable eth; + + /// @dev WETH contract + IWETH private immutable weth; + + /// @dev Withdrawer + Withdrawer private immutable withdrawer; + + constructor( + address[] memory vaults, + CurvePool[] memory pools, + Withdrawer _withdrawer, + address _eth, + address _weth + ) { + uint256 vaultsLength = vaults.length; + require(vaultsLength == pools.length, "YCVO: INVALID_VAULTS_LENGTH"); + operatorStorage = new YearnVaultStorage(); + + for (uint256 i; i < vaultsLength; i++) { + operatorStorage.addVault(vaults[i], pools[i]); + } + + operatorStorage.transferOwnership(msg.sender); + + eth = _eth; + weth = IWETH(_weth); + withdrawer = _withdrawer; + } + + /// @notice Add liquidity in a Curve pool that includes ETH, + /// deposit the LP token in a Yearn vault and receive + /// the Yearn vault shares + /// @param vault The Yearn vault address to deposit into + /// @param amount The amount of token to add liquidity + /// @param minVaultAmount The minimum of Yearn vault shares expected + /// @return amounts Array of amounts : + /// - [0] : The vault token received amount + /// - [1] : The token deposited amount + /// @return tokens Array of token addresses + /// - [0] : The vault token received address + /// - [1] : The token deposited address + function depositETH( + address vault, + uint256 amount, + uint256 minVaultAmount + ) external payable returns (uint256[] memory amounts, address[] memory tokens) { + require(amount != 0, "YCVO: INVALID_AMOUNT"); + + (address pool, uint96 poolCoinAmount, address lpToken) = operatorStorage.vaults(vault); + require(pool != address(0), "YCVO: INVALID_VAULT"); + + uint256 vaultBalanceBefore = IERC20(vault).balanceOf(address(this)); + uint256 ethBalanceBefore = weth.balanceOf(address(this)); + + ExchangeHelpers.setMaxAllowance(IERC20(address(weth)), address(withdrawer)); + + // withdraw ETH from WETH + withdrawer.withdraw(amount); + + StakingLPVaultHelpers._addLiquidityAndDepositETH( + vault, + ICurvePoolETH(pool), + IERC20(lpToken), + poolCoinAmount, + eth, + amount + ); + + (amounts, tokens) = OperatorHelpers.getOutputAmounts( + IERC20(address(weth)), + ethBalanceBefore, + amount, + IERC20(vault), + vaultBalanceBefore, + minVaultAmount + ); + } + + /// @notice Add liquidity in a Curve pool, deposit + /// the LP token in a Yearn vault and receive + /// the Yearn vault shares + /// @param vault The Yearn vault address to deposit into + /// @param token The token to add liquidity + /// @param amount The amount of token to add liquidity + /// @param minVaultAmount The minimum of Yearn vault shares expected + /// @return amounts Array of amounts : + /// - [0] : The vault token received amount + /// - [1] : The token deposited amount + /// @return tokens Array of token addresses + /// - [0] : The vault token received address + /// - [1] : The token deposited address + function deposit( + address vault, + address token, + uint256 amount, + uint256 minVaultAmount + ) external payable returns (uint256[] memory amounts, address[] memory tokens) { + require(amount != 0, "YCVO: INVALID_AMOUNT"); + (address pool, uint96 poolCoinAmount, address lpToken) = operatorStorage.vaults(vault); + require(pool != address(0), "YCVO: INVALID_VAULT"); + + uint256 vaultBalanceBefore = IERC20(vault).balanceOf(address(this)); + uint256 tokenBalanceBefore = IERC20(token).balanceOf(address(this)); + + StakingLPVaultHelpers._addLiquidityAndDeposit( + vault, + ICurvePoolNonETH(pool), + IERC20(lpToken), + poolCoinAmount, + token, + amount + ); + + (amounts, tokens) = OperatorHelpers.getOutputAmounts( + IERC20(token), + tokenBalanceBefore, + amount, + IERC20(vault), + vaultBalanceBefore, + minVaultAmount + ); + } + + /// @notice Withdraw the LP token from the Yearn vault, + /// remove ETH liquidity from the Curve pool + /// and receive one of the curve pool token + /// @param vault The Yearn vault address to withdraw from + /// @param amount The amount to withdraw + /// @param minAmountOut The minimum of output token expected + /// @return amounts Array of amounts : + /// - [0] : The token received amount + /// - [1] : The vault token deposited amount + /// @return tokens Array of token addresses + /// - [0] : The token received address + /// - [1] : The vault token deposited address + function withdrawETH( + address vault, + uint256 amount, + uint256 minAmountOut + ) external payable returns (uint256[] memory amounts, address[] memory tokens) { + require(amount != 0, "YCVO: INVALID_AMOUNT"); + + (address pool, uint96 poolCoinAmount, address lpToken) = operatorStorage.vaults(vault); + require(pool != address(0), "YCVO: INVALID_VAULT"); + + uint256 vaultBalanceBefore = IERC20(vault).balanceOf(address(this)); + uint256 tokenBalanceBefore = weth.balanceOf(address(this)); + + StakingLPVaultHelpers._withdrawAndRemoveLiquidity128( + vault, + amount, + ICurvePool(pool), + IERC20(lpToken), + poolCoinAmount, + eth + ); + + (amounts, tokens) = OperatorHelpers.getOutputAmounts( + IERC20(vault), + vaultBalanceBefore, + amount, + IERC20(address(weth)), + tokenBalanceBefore, + minAmountOut + ); + } + + /// @notice Withdraw the LP token from the Yearn vault, + /// remove the liquidity from the Curve pool + /// (using int128 for the curvePool.remove_liquidity_one_coin + /// coin index parameter) and receive one of the + /// curve pool token + /// @param vault The Yearn vault address to withdraw from + /// @param amount The amount to withdraw + /// @param outputToken Output token to receive + /// @param minAmountOut The minimum of output token expected + /// @return amounts Array of amounts : + /// - [0] : The token received amount + /// - [1] : The vault token deposited amount + /// @return tokens Array of token addresses + /// - [0] : The token received address + /// - [1] : The vault token deposited address + function withdraw128( + address vault, + uint256 amount, + IERC20 outputToken, + uint256 minAmountOut + ) external payable returns (uint256[] memory amounts, address[] memory tokens) { + require(amount != 0, "YCVO: INVALID_AMOUNT"); + + (address pool, uint96 poolCoinAmount, address lpToken) = operatorStorage.vaults(vault); + require(pool != address(0), "YCVO: INVALID_VAULT"); + + uint256 vaultBalanceBefore = IERC20(vault).balanceOf(address(this)); + uint256 tokenBalanceBefore = outputToken.balanceOf(address(this)); + + StakingLPVaultHelpers._withdrawAndRemoveLiquidity128( + vault, + amount, + ICurvePool(pool), + IERC20(lpToken), + poolCoinAmount, + address(outputToken) + ); + + (amounts, tokens) = OperatorHelpers.getOutputAmounts( + IERC20(vault), + vaultBalanceBefore, + amount, + outputToken, + tokenBalanceBefore, + minAmountOut + ); + } + + /// @notice Withdraw the LP token from the Yearn vault, + /// remove the liquidity from the Curve pool + /// (using uint256 for the curvePool.remove_liquidity_one_coin + /// coin index parameter) and receive one of the + /// curve pool token + /// @param vault The Yearn vault address to withdraw from + /// @param amount The amount to withdraw + /// @param outputToken Output token to receive + /// @param minAmountOut The minimum of output token expected + /// @return amounts Array of amounts : + /// - [0] : The token received amount + /// - [1] : The vault token deposited amount + /// @return tokens Array of token addresses + /// - [0] : The token received address + /// - [1] : The vault token deposited address + function withdraw256( + address vault, + uint256 amount, + IERC20 outputToken, + uint256 minAmountOut + ) external payable returns (uint256[] memory amounts, address[] memory tokens) { + require(amount != 0, "YCVO: INVALID_AMOUNT"); + + (address pool, uint96 poolCoinAmount, address lpToken) = operatorStorage.vaults(vault); + require(pool != address(0), "YCVO: INVALID_VAULT"); + + uint256 vaultBalanceBefore = IERC20(vault).balanceOf(address(this)); + uint256 tokenBalanceBefore = outputToken.balanceOf(address(this)); + + StakingLPVaultHelpers._withdrawAndRemoveLiquidity256( + vault, + amount, + ICurvePoolNonETH(pool), + IERC20(lpToken), + poolCoinAmount, + address(outputToken) + ); + + (amounts, tokens) = OperatorHelpers.getOutputAmounts( + IERC20(vault), + vaultBalanceBefore, + amount, + outputToken, + tokenBalanceBefore, + minAmountOut + ); + } +} diff --git a/contracts/operators/Yearn/YearnVaultStorage.sol b/contracts/operators/Yearn/YearnVaultStorage.sol new file mode 100644 index 00000000..9708f1c3 --- /dev/null +++ b/contracts/operators/Yearn/YearnVaultStorage.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.11; + +import "@openzeppelin/contracts/access/Ownable.sol"; + +struct CurvePool { + address poolAddress; + uint96 poolCoinAmount; + address lpToken; +} + +/// @title YearnVaultStorage storage contract +contract YearnVaultStorage is Ownable { + /// @dev Emitted when a vault is added + /// @param vault The vault address + /// @param pool The underlying CurvePool + event VaultAdded(address vault, CurvePool pool); + + /// @dev Emitted when a vault is removed + /// @param vault The removed vault address + event VaultRemoved(address vault); + + /// @dev Map of vault address with underlying CurvePool + mapping(address => CurvePool) public vaults; + + /// @notice Add a Yearn Curve vault + /// @param vault The vault address + /// @param curvePool The underlying CurvePool (used to add liquidity) + function addVault(address vault, CurvePool calldata curvePool) external onlyOwner { + require(vault != address(0), "YVS: INVALID_VAULT_ADDRESS"); + require(curvePool.poolAddress != address(0), "YVS: INVALID_POOL_ADDRESS"); + require(curvePool.lpToken != address(0), "YVS: INVALID_TOKEN_ADDRESS"); + require(vaults[vault].poolAddress == address(0), "YVS: VAULT_ALREADY_HAS_POOL"); + require(vaults[vault].lpToken == address(0), "YVS: VAULT_ALREADY_HAS_LP"); + vaults[vault] = curvePool; + emit VaultAdded(vault, curvePool); + } + + /// @notice Remove a Yearn vault + /// @param vault The vault address to remove + function removeVault(address vault) external onlyOwner { + require(vaults[vault].poolAddress != address(0), "YVS: NON_EXISTENT_VAULT"); + delete vaults[vault]; + emit VaultRemoved(vault); + } +} diff --git a/scripts/utils.ts b/scripts/utils.ts index c844bb9c..3ef0cbdf 100644 --- a/scripts/utils.ts +++ b/scripts/utils.ts @@ -1,18 +1,20 @@ import { BeefyVaultOperator, - BeefyZapBiswapLPVaultOperator, - BeefyZapUniswapLPVaultOperator, FlatOperator, NestedFactory, OperatorResolver, ParaswapOperator, + StakeDaoCurveStrategyOperator, ZeroExOperator, } from "../typechain"; -import { FactoryAndOperatorsFixture, FactoryAndOperatorsForkingBSCFixture } from "../test/shared/fixtures"; -import * as ethers from "ethers"; - -import { BigNumber, BigNumberish, BytesLike } from "ethers"; +import { FactoryAndOperatorsFixture, FactoryAndOperatorsForkingBSCFixture, FactoryAndOperatorsForkingETHFixture } from "../test/shared/fixtures"; +import * as ethers from "ethers" +import { BigNumber, BigNumberish, BytesLike, Wallet } from "ethers"; import * as w3utils from "web3-utils"; +import { UINT256_MAX } from "../test/helpers"; +import { YearnCurveVaultOperator } from "../typechain/YearnCurveVaultOperator"; +import { BeefyZapUniswapLPVaultOperator } from "../typechain/BeefyZapUniswapLPVaultOperator"; +import { BeefyZapBiswapLPVaultOperator } from "../typechain/BeefyZapBiswapLPVaultOperator"; type RawDataType = "address" | "bytes4" | "bytes" | "uint256"; interface Op { @@ -47,14 +49,14 @@ export const dummyRouterSelector = "0x76ab33a6"; export const abiCoder = new ethers.utils.AbiCoder(); -export function buildOrderStruct(operator: string, outToken: string, data: [RawDataType, any][]): OrderStruct { +export function buildOrderStruct(operator: string, token: string, data: [RawDataType, any][]): OrderStruct { const abiCoder = new ethers.utils.AbiCoder(); const coded = abiCoder.encode([...data.map(x => x[0])], [...data.map(x => x[1])]); return { // specify which operator? operator: operator, - // specify the token that this order will output - token: outToken, + // specify the token that this order will retrieve from Reserve or Wallet + token: token, // encode the given data callData: coded, // remove the leading 32 bytes (one address) and the leading 0x // callData, @@ -150,6 +152,46 @@ export function registerFlat(operator: FlatOperator): Op { }; } +export function registerYearnDeposit(operator: YearnCurveVaultOperator): Op { + return { + name: "YearnVaultDepositOperator", + contract: operator.address, + signature: "function deposit(address vault, address token, uint256 amount, uint256 minVaultAmount)", + }; +} + +export function registerYearnDepositETH(operator: YearnCurveVaultOperator): Op { + return { + name: "YearnVaultDepositETHOperator", + contract: operator.address, + signature: "function depositETH(address vault, uint256 amount, uint256 minVaultAmount)", + }; +} + +export function registerYearnWithdraw128(operator: YearnCurveVaultOperator): Op { + return { + name: "YearnVaultWithdraw128Operator", + contract: operator.address, + signature: "function withdraw128(address vault, uint256 amount, address outputToken, uint256 minAmountOut)", + }; +} + +export function registerYearnWithdraw256(operator: YearnCurveVaultOperator): Op { + return { + name: "YearnVaultWithdraw256Operator", + contract: operator.address, + signature: "function withdraw256(address vault, uint256 amount, address outputToken, uint256 minAmountOut)", + }; +} + +export function registerYearnWithdrawETH(operator: YearnCurveVaultOperator): Op { + return { + name: "YearnVaultWithdrawETHOperator", + contract: operator.address, + signature: "function withdrawETH(address vault, uint256 amount, uint256 minAmountOut)", + }; +} + export function registerBeefyDeposit(operator: BeefyVaultOperator): Op { return { name: "BeefyDeposit", @@ -166,6 +208,46 @@ export function registerBeefyWithdraw(operator: BeefyVaultOperator): Op { }; } +export function registerStakeDaoDepositETH(operator: StakeDaoCurveStrategyOperator): Op { + return { + name: "stakeDaoCurveStrategyDepositETH", + contract: operator.address, + signature: "function depositETH(address strategy, uint256 amount, uint256 minAmountOut)" + }; +} + +export function registerStakeDaoDeposit(operator: StakeDaoCurveStrategyOperator): Op { + return { + name: "stakeDaoCurveStrategyDeposit", + contract: operator.address, + signature: "function deposit(address strategy, address tokenIn, uint256 amount, uint256 minAmountOut)" + }; +} + +export function registerStakeDaoWithdrawETH(operator: StakeDaoCurveStrategyOperator): Op { + return { + name: "stakeDaoCurveStrategyWithdrawETH", + contract: operator.address, + signature: "function withdrawETH(address strategy, uint256 amount, uint256 minAmountOut)" + }; +} + +export function registerStakeDaoWithdraw128(operator: StakeDaoCurveStrategyOperator): Op { + return { + name: "stakeDaoCurveStrategyWithdraw128", + contract: operator.address, + signature: "function withdraw128(address strategy, uint256 amount, address outputToken, uint256 minAmountOut)" + }; +} + +export function registerStakeDaoWithdraw256(operator: StakeDaoCurveStrategyOperator): Op { + return { + name: "stakeDaoCurveStrategyWithdraw256", + contract: operator.address, + signature: "function withdraw256(address strategy, uint256 amount, address outputToken, uint256 minAmountOut)" + } +} + export function registerBeefyZapBiswapLPDeposit(operator: BeefyZapBiswapLPVaultOperator): Op { return { name: "BeefyZapBiswapLPDeposit", @@ -289,6 +371,128 @@ export function getUniAndKncWithETHOrders( ]; } +// Create a non-ETH Deposit order in yearn +export function getYearnCurveDepositOrder(context: FactoryAndOperatorsForkingETHFixture, yearnVaultAddress: string, tokenToDeposit: string, amountToDeposit: BigNumber, minVaultAmount?: BigNumber) { + return [ + buildOrderStruct(context.yearnVaultDepositOperatorNameBytes32, yearnVaultAddress, [ + ["address", yearnVaultAddress], + ["address", tokenToDeposit], + ["uint256", amountToDeposit], + ["uint256", minVaultAmount ? minVaultAmount : 0], // 100% slippage + ]), + ]; +} + +// Create an ETH Deposit order in yearn +export function getYearnCurveDepositETHOrder(context: FactoryAndOperatorsForkingETHFixture, yearnVaultAddress: string, amountToDeposit: BigNumber, minVaultAmount?: BigNumber) { + return [ + buildOrderStruct(context.yearnVaultDepositETHOperatorNameBytes32, yearnVaultAddress, [ + ["address", yearnVaultAddress], + ["uint256", amountToDeposit], + ["uint256", minVaultAmount ? minVaultAmount : 0], // 100% slippage + ]), + ]; +} + +// Create a Withdraw256 order in yearn (for Curve pool that require a uint256 index param in the function remove_liquidity_one_coin) +export function getYearnCurveWithdraw256Order(context: FactoryAndOperatorsForkingETHFixture, yearnVaultAddress: string, amountToWithdraw: BigNumber, outputToken: string, minAmountOut?: BigNumber) { + return [ + buildOrderStruct(context.yearnVaultWithdraw256OperatorNameBytes32, yearnVaultAddress, [ + ["address", yearnVaultAddress], + ["uint256", amountToWithdraw], + ["address", outputToken], + ["uint256", minAmountOut ? minAmountOut : 0], // 100% slippage + ]), + ]; +} + +// Create a Withdraw128 order in yearn (for Curve pool that require a int128 index param in the function remove_liquidity_one_coin) +export function getYearnCurveWithdraw128Order(context: FactoryAndOperatorsForkingETHFixture, yearnVaultAddress: string, amountToWithdraw: BigNumber, outputToken: string, minAmountOut?: BigNumber) { + return [ + buildOrderStruct(context.yearnVaultWithdraw128OperatorNameBytes32, yearnVaultAddress, [ + ["address", yearnVaultAddress], + ["uint256", amountToWithdraw], + ["address", outputToken], + ["uint256", minAmountOut ? minAmountOut : 0], // 100% slippage + ]), + ]; +} + +// Create a WithdrawETH order in yearn +export function getYearnCurveWithdrawETHOrder(context: FactoryAndOperatorsForkingETHFixture, yearnVaultAddress: string, amountToWithdraw: BigNumber, minAmountOut?: BigNumber) { + return [ + buildOrderStruct(context.yearnVaultWithdrawETHOperatorNameBytes32, yearnVaultAddress, [ + ["address", yearnVaultAddress], + ["uint256", amountToWithdraw], + ["uint256", minAmountOut ? minAmountOut : 0], // 100% slippage + ]), + ]; +} + + + +// Create an ETH Deposit order in StakeDAO +export function getStakeDaoDepositETHOrder(context: FactoryAndOperatorsForkingETHFixture, strategyAddress: string, amountToDeposit: BigNumber, minStrategyToken?: BigNumber) { + return [ + buildOrderStruct(context.stakeDaoCurveStrategyDepositETHOperatorNameBytes32, strategyAddress, [ + ["address", strategyAddress], + ["uint256", amountToDeposit], + ["uint256", minStrategyToken ? minStrategyToken : 0], // 100% slippage if minAmountOut is null + ]), + ]; +} + + +// Create a non-ETH Deposit order in StakeDAO +export function getStakeDaoDepositOrder(context: FactoryAndOperatorsForkingBSCFixture, strategyAddress: string, tokenToDeposit: string, amountToDeposit: BigNumber, minStrategyToken?: BigNumber) { + return [ + buildOrderStruct(context.stakeDaoCurveStrategyDepositOperatorNameBytes32, strategyAddress, [ + ["address", strategyAddress], + ["address", tokenToDeposit], + ["uint256", amountToDeposit], + ["uint256", minStrategyToken ? minStrategyToken : 0], // 100% slippage if minAmountOut is null + ]), + ]; +} + + +// Create a WithdrawETH order in StakeDAO +export function getStakeDaoWithdrawETHOrder(context: FactoryAndOperatorsForkingETHFixture, strategyAddress: string, amountToWithdraw: BigNumber, minAmountOut?: BigNumber) { + return [ + buildOrderStruct(context.stakeDaoCurveStrategyWithdrawETHOperatorNameBytes32, strategyAddress, [ + ["address", strategyAddress], + ["uint256", amountToWithdraw], + ["uint256", minAmountOut ? minAmountOut : 0], // 100% slippage if minAmountOut is null + ]), + ]; +} + + +// Create a Withdraw128 order in StakeDAO +export function getStakeDaoWithdraw128Order(context: FactoryAndOperatorsForkingBSCFixture, strategyAddress: string, amountToWithdraw: BigNumber, outputToken: string, minAmountOut?: BigNumber) { + return [ + buildOrderStruct(context.stakeDaoCurveStrategyWithdraw128OperatorNameBytes32, strategyAddress, [ + ["address", strategyAddress], + ["uint256", amountToWithdraw], + ["address", outputToken], + ["uint256", minAmountOut ? minAmountOut : 0], // 100% slippage if minAmountOut is null + ]), + ]; +} + + +// Create a Withdraw256 order in StakeDAO +export function getStakeDaoWithdraw256Order(context: FactoryAndOperatorsForkingBSCFixture, strategyAddress: string, amountToWithdraw: BigNumber, outputToken: string, minAmountOut?: BigNumber) { + return [ + buildOrderStruct(context.stakeDaoCurveStrategyWithdraw256OperatorNameBytes32, strategyAddress, [ + ["address", strategyAddress], + ["uint256", amountToWithdraw], + ["address", outputToken], + ["uint256", minAmountOut ? minAmountOut : 0], // 100% slippage if minAmountOut is null + ]), + ]; +} + // Create a Deposit order in Beefy (BNB Venus Vault on BSC) export function getBeefyBnbVenusDepositOrder(context: FactoryAndOperatorsForkingBSCFixture, bnbToDeposit: BigNumber) { return [ @@ -382,6 +586,7 @@ export function getBeefyBiswapWithdrawOrder( ]; } + // Generic function to create a 1:1 Order export function getTokenBWithTokenAOrders( context: FactoryAndOperatorsFixture, @@ -479,3 +684,19 @@ export function getWethWithUniAndKncOrders( ]), ]; } + +export const setMaxAllowance = async (signer: Wallet, spender: string, contract: string) => { + const data = + ethers.utils.keccak256( + ethers.utils.toUtf8Bytes("increaseAllowance(address,uint256)") + ).slice(0, 10) + + abiCoder.encode( + ["address", "uint256"], + [spender, UINT256_MAX] + ).slice(2, 1000) + + await signer.sendTransaction({ + to: contract, + data: data + }) +} \ No newline at end of file diff --git a/test/helpers.ts b/test/helpers.ts index 1ca715f5..65455cb8 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,8 +1,11 @@ import { ethers } from "hardhat"; -import { BigNumber } from "ethers"; +import { BigNumber, Wallet } from "ethers"; +import { string } from "hardhat/internal/core/params/argumentTypes"; const w3utils = require("web3-utils"); +const abiCoder = new ethers.utils.AbiCoder(); export const appendDecimals = (amount: number) => ethers.utils.parseEther(amount.toString()); +export const append6Decimals = (amount: number) => { return BigNumber.from(amount).mul(10 ** 6) }; // needed for EURT that has 6 decimals export const getETHSpentOnGas = async (tx: any) => { const receipt = await tx.wait(); @@ -21,3 +24,19 @@ export const fromBytes32 = (key: string) => w3utils.hexToAscii(key); export function getExpectedFees(amount: BigNumber) { return amount.div(100); } + +export const setAllowance = async (signer: Wallet, contract: string, spender: string, amount: BigNumber) => { + const data = + ethers.utils.keccak256( + ethers.utils.toUtf8Bytes("approve(address,uint256)") + ).slice(0, 10) + + abiCoder.encode( + ["address", "uint256"], + [spender, amount] + ).slice(2, 130) + + await signer.sendTransaction({ + to: contract, + data: data + }) +} diff --git a/test/shared/fixtures.ts b/test/shared/fixtures.ts index 8dde09be..9a3a22a8 100644 --- a/test/shared/fixtures.ts +++ b/test/shared/fixtures.ts @@ -1,6 +1,7 @@ import { Fixture } from "ethereum-waffle"; import { ethers, network } from "hardhat"; import { ActorFixture } from "./actors"; +import { addEthEurtBalanceTo, addBscUsdcBalanceTo } from "./impersonnate" import { AugustusSwapper, @@ -18,14 +19,18 @@ import { NestedReserve, OperatorResolver, ParaswapOperator, + StakeDaoCurveStrategyOperator, + StakeDaoStrategyStorage, TestableOperatorCaller, WETH9, Withdrawer, + YearnCurveVaultOperator, + YearnVaultStorage, ZeroExOperator, } from "../../typechain"; import { BigNumber, Wallet } from "ethers"; import { Interface } from "ethers/lib/utils"; -import { appendDecimals, toBytes32 } from "../helpers"; +import { append6Decimals, appendDecimals, getExpectedFees, setAllowance, toBytes32, UINT256_MAX } from "../helpers"; import { importOperatorsWithSigner, registerFlat, @@ -37,10 +42,28 @@ import { registerBeefyZapBiswapLPWithdraw, registerBeefyZapUniswapLPDeposit, registerBeefyZapUniswapLPWithdraw, + registerYearnDeposit, + registerYearnWithdraw128, + registerYearnWithdraw256, + registerYearnDepositETH, + registerYearnWithdrawETH, + setMaxAllowance, + registerStakeDaoDeposit, + registerStakeDaoDepositETH, + registerStakeDaoWithdrawETH, + registerStakeDaoWithdraw128, + registerStakeDaoWithdraw256, } from "../../scripts/utils"; + import { BeefyZapBiswapLPVaultOperator } from "../../typechain/BeefyZapBiswapLPVaultOperator"; export type OperatorResolverFixture = { operatorResolver: OperatorResolver }; +// Token addresses on mainnet +export const USDCEth = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" +export const EURTEth = "0xC581b735A1688071A1746c968e0798D642EDE491" + +// Token addresses on BSC +export const USDCBsc = "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d"; export const operatorResolverFixture: Fixture = async (wallets, provider) => { const signer = new ActorFixture(wallets as Wallet[], provider).addressResolverOwner(); @@ -53,7 +76,6 @@ export const operatorResolverFixture: Fixture = async ( const operatorResolver = await operatorResolverFactory.connect(signer).deploy(); return { operatorResolver }; - return { operatorResolver }; }; export type ZeroExOperatorFixture = { @@ -426,6 +448,15 @@ export type FactoryAndOperatorsForkingBSCFixture = { beefyVaultStorage: BeefyVaultStorage; beefyVaultDepositOperatorNameBytes32: string; beefyVaultWithdrawOperatorNameBytes32: string; + stakeDaoUsdStrategyAddress: string; + stakeDaoNonWhitelistedStrategy: string; + stakeDaoCurveStrategyOperator: StakeDaoCurveStrategyOperator; + stakeDaoStrategyStorage: StakeDaoStrategyStorage; + stakeDaoCurveStrategyDepositOperatorNameBytes32: string; + stakeDaoCurveStrategyDepositETHOperatorNameBytes32: string; + stakeDaoCurveStrategyWithdrawETHOperatorNameBytes32: string; + stakeDaoCurveStrategyWithdraw128OperatorNameBytes32: string; + stakeDaoCurveStrategyWithdraw256OperatorNameBytes32: string; beefyBiswapVaultAddress: string; beefyUnregisteredBiswapVaultAddress: string; beefyBiswapBtcEthLPVaultAddress: string; @@ -454,6 +485,7 @@ export const factoryAndOperatorsForkingBSCFixture: Fixture { const masterDeployer = new ActorFixture(wallets as Wallet[], provider).masterDeployer(); + const BNB = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"; const WBNBFactory = await ethers.getContractFactory("WETH9"); const WBNB = await WBNBFactory.attach("0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c"); @@ -494,6 +526,11 @@ export const factoryAndOperatorsForkingBSCFixture: Fixture = async ( + wallets, + provider, +) => { + const masterDeployer = new ActorFixture(wallets as Wallet[], provider).masterDeployer(); + + const ETH = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"; + const WETHFactory = await ethers.getContractFactory("WETH9"); + const WETH = await WETHFactory.attach("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"); + + // Get the Fee shareholders (two actors) + const shareholder1 = new ActorFixture(wallets as Wallet[], provider).shareHolder1(); + const shareholder2 = new ActorFixture(wallets as Wallet[], provider).shareHolder2(); + + // Define the royaltie weight value (used in FeeSplitter) + const royaltieWeigth = BigNumber.from(300); + + // Deploy the FeeSplitter + const feeSplitterFactory = await ethers.getContractFactory("FeeSplitter"); + const feeSplitter = await feeSplitterFactory + .connect(masterDeployer) + .deploy([shareholder1.address, shareholder2.address], [150, 150], royaltieWeigth, WETH.address); + await feeSplitter.deployed(); + + // Deploy NestedAsset + const nestedAssetFactory = await ethers.getContractFactory("NestedAsset"); + const nestedAsset = await nestedAssetFactory.connect(masterDeployer).deploy(); + await nestedAsset.deployed(); + + // Define maxHoldingsCount value (used in NestedRecords) + const maxHoldingsCount = BigNumber.from(15); + + // Deploy NestedRecords + const nestedRecordsFactory = await ethers.getContractFactory("NestedRecords"); + const nestedRecords = await nestedRecordsFactory.connect(masterDeployer).deploy(maxHoldingsCount); + await nestedRecords.deployed(); + + // Deploy Reserve + const nestedReserveFactory = await ethers.getContractFactory("NestedReserve"); + const nestedReserve = await nestedReserveFactory.connect(masterDeployer).deploy(); + await nestedReserve.deployed(); + + // Deploy OperatorResolver + const operatorResolverFactory = await ethers.getContractFactory("OperatorResolver"); + const operatorResolver = await operatorResolverFactory.connect(masterDeployer).deploy(); + await operatorResolver.deployed(); + + // Deploy Withdrawer + const withdrawerFactory = await ethers.getContractFactory("Withdrawer"); + const withdrawer = await withdrawerFactory.connect(masterDeployer).deploy(WETH.address); + await withdrawer.deployed(); + + // Deploy StakeDAO operator storage (USD Strategy 3pool) + const stakeDaoStEthStrategyAddress = "0xbC10c4F7B9FE0B305e8639B04c536633A3dB7065"; + const stEthPoolAddress = "0xDC24316b9AE028F1497c275EB9192a3Ea0f67022"; + const stEthPoolLpTokenAddress = "0x06325440D014e39736583c165C2963BA99fAf14E"; + const stakeDaoCurveStrategyOperatorFactory = await ethers.getContractFactory("StakeDaoCurveStrategyOperator"); + const stakeDaoCurveStrategyOperator = await stakeDaoCurveStrategyOperatorFactory + .connect(masterDeployer) + .deploy( + [stakeDaoStEthStrategyAddress], + [{ + poolAddress: stEthPoolAddress, + poolCoinAmount: 2, + lpToken: stEthPoolLpTokenAddress + }], + withdrawer.address, + ETH, + WETH.address + ); + await stakeDaoCurveStrategyOperator.deployed(); + + const stakeDaoStrategyStorageFactory = await ethers.getContractFactory("StakeDaoStrategyStorage"); + const stakeDaoStrategyStorage = stakeDaoStrategyStorageFactory.attach(await stakeDaoCurveStrategyOperator.operatorStorage()); + + // Deploy Yearn Curve operator (Curve 3crypto, Curve alETH and Curve 3EUR) + const triCryptoVault = "0xE537B5cc158EB71037D4125BDD7538421981E6AA"; + const curveTriCryptoPoolAddress = "0xD51a44d3FaE010294C616388b506AcdA1bfAAE46"; + const curveTriCryptoLpTokenAddress = "0xc4AD29ba4B3c580e6D59105FFf484999997675Ff"; + + const alEthVault = "0x718AbE90777F5B778B52D553a5aBaa148DD0dc5D"; + const curveAlEthFactoryPoolAddress = "0xC4C319E2D4d66CcA4464C0c2B32c9Bd23ebe784e"; + const curveAlEthFactoryLpTokenAddress = "0xC4C319E2D4d66CcA4464C0c2B32c9Bd23ebe784e"; + + const threeEurVault = "0x5AB64C599FcC59f0f2726A300b03166A395578Da"; + const curveThreeEurPoolAddress = "0xb9446c4Ef5EBE66268dA6700D26f96273DE3d571"; + const curveThreeEurLpTokenAddress = "0xb9446c4Ef5EBE66268dA6700D26f96273DE3d571"; + + const nonWhitelistedVault = "0x3B96d491f067912D18563d56858Ba7d6EC67a6fa" + + const yearnCurveVaultOperatorFactory = await ethers.getContractFactory("YearnCurveVaultOperator"); + const yearnCurveVaultOperator = await yearnCurveVaultOperatorFactory + .connect(masterDeployer) + .deploy( + [ + triCryptoVault, + alEthVault, + threeEurVault + ], + [ + { + poolAddress: curveTriCryptoPoolAddress, + poolCoinAmount: 3, + lpToken: curveTriCryptoLpTokenAddress + }, + { + poolAddress: curveAlEthFactoryPoolAddress, + poolCoinAmount: 2, + lpToken: curveAlEthFactoryLpTokenAddress + }, + { + poolAddress: curveThreeEurPoolAddress, + poolCoinAmount: 3, + lpToken: curveThreeEurLpTokenAddress + } + + ], + withdrawer.address, + ETH, + WETH.address + ); + + await yearnCurveVaultOperator.deployed(); + + const yearnVaultStorageFactory = await ethers.getContractFactory("YearnVaultStorage"); + const yearnVaultStorage = yearnVaultStorageFactory.attach(await yearnCurveVaultOperator.operatorStorage()); + + // Deploy NestedFactory + const nestedFactoryFactory = await ethers.getContractFactory("NestedFactory"); + const nestedFactoryImpl = await nestedFactoryFactory + .connect(masterDeployer) + .deploy( + nestedAsset.address, + nestedRecords.address, + nestedReserve.address, + feeSplitter.address, + WETH.address, + operatorResolver.address, + withdrawer.address, + ); + await nestedFactoryImpl.deployed(); + + // Get the user1 actor + const user1 = new ActorFixture(wallets as Wallet[], provider).user1(); + + // add ether to wallets + await network.provider.send("hardhat_setBalance", [ + masterDeployer.address, + appendDecimals(100000000000000000).toHexString(), + ]); + await network.provider.send("hardhat_setBalance", [ + user1.address, + appendDecimals(100000000000000000).toHexString(), + ]); + + // Deploy FactoryProxy + const transparentUpgradeableProxyFactory = await ethers.getContractFactory("TransparentUpgradeableProxy"); + const factoryProxy = await transparentUpgradeableProxyFactory.deploy( + nestedFactoryImpl.address, + masterDeployer.address, + [], + ); + + // Set factory to asset, records and reserve + let tx = await nestedAsset.addFactory(factoryProxy.address); + await tx.wait(); + tx = await nestedRecords.addFactory(factoryProxy.address); + await tx.wait(); + tx = await nestedReserve.addFactory(factoryProxy.address); + await tx.wait(); + + // Initialize the owner in proxy storage by calling upgradeToAndCall + // It will upgrade with the same address (no side effects) + const initData = await nestedFactoryImpl.interface.encodeFunctionData("initialize", [masterDeployer.address]); + tx = await factoryProxy.connect(masterDeployer).upgradeToAndCall(nestedFactoryImpl.address, initData); + await tx.wait(); + + // Set multisig as admin of proxy, so we can call the implementation as owner + const proxyAdmin = new ActorFixture(wallets as Wallet[], provider).proxyAdmin(); + tx = await factoryProxy.connect(masterDeployer).changeAdmin(proxyAdmin.address); + await tx.wait(); + + // Attach factory impl to proxy address + const nestedFactory = await nestedFactoryFactory.attach(factoryProxy.address); + + // Reset feeSplitter in proxy storage + tx = await nestedFactory.connect(masterDeployer).setFeeSplitter(feeSplitter.address); + await tx.wait(); + + // Set entry fees in proxy storage + tx = await nestedFactory.connect(masterDeployer).setEntryFees(100); + await tx.wait(); + + // Set exit fees in proxy storage + tx = await nestedFactory.connect(masterDeployer).setExitFees(100); + await tx.wait(); + + await importOperatorsWithSigner( + operatorResolver, + [ + registerStakeDaoDeposit(stakeDaoCurveStrategyOperator), + registerStakeDaoDepositETH(stakeDaoCurveStrategyOperator), + registerStakeDaoWithdraw128(stakeDaoCurveStrategyOperator), + registerStakeDaoWithdraw256(stakeDaoCurveStrategyOperator), + registerStakeDaoWithdrawETH(stakeDaoCurveStrategyOperator), + registerYearnDeposit(yearnCurveVaultOperator), + registerYearnDepositETH(yearnCurveVaultOperator), + registerYearnWithdraw128(yearnCurveVaultOperator), + registerYearnWithdraw256(yearnCurveVaultOperator), + registerYearnWithdrawETH(yearnCurveVaultOperator) + ], + nestedFactory, + masterDeployer, + ); + + // Set factory to asset, records and reserve + await nestedAsset.connect(masterDeployer).addFactory(nestedFactory.address); + await nestedRecords.connect(masterDeployer).addFactory(nestedFactory.address); + await nestedReserve.connect(masterDeployer).addFactory(nestedFactory.address); + + // Deploy NestedAssetBatcher + const nestedAssetBatcherFactory = await ethers.getContractFactory("NestedAssetBatcher"); + const nestedAssetBatcher = await nestedAssetBatcherFactory + .connect(masterDeployer) + .deploy(nestedAsset.address, nestedRecords.address); + await nestedAssetBatcher.deployed(); + + // Define the base amount + const baseAmount = appendDecimals(1000); + + const eurtToAddToBalance = append6Decimals(1000); + const eurtToAddToBalanceAndFees = eurtToAddToBalance.add(getExpectedFees(eurtToAddToBalance)); + // Add fund ThreeEur to balance + await addEthEurtBalanceTo(user1, eurtToAddToBalanceAndFees); + await setAllowance(user1, EURTEth, nestedFactory.address, UINT256_MAX); + + return { + WETH, + shareholder1, + shareholder2, + feeSplitter, + royaltieWeigth, + nestedAsset, + nestedRecords, + maxHoldingsCount, + operatorResolver, + stakeDaoStEthStrategyAddress, + stakeDaoNonWhitelistedStrategy: "0xa2761B0539374EB7AF2155f76eb09864af075250", + stakeDaoCurveStrategyOperator, + stakeDaoStrategyStorage, + stakeDaoCurveStrategyDepositOperatorNameBytes32: toBytes32("stakeDaoCurveStrategyDeposit"), + stakeDaoCurveStrategyDepositETHOperatorNameBytes32: toBytes32("stakeDaoCurveStrategyDepositETH"), + stakeDaoCurveStrategyWithdrawETHOperatorNameBytes32: toBytes32("stakeDaoCurveStrategyWithdrawETH"), + stakeDaoCurveStrategyWithdraw128OperatorNameBytes32: toBytes32("stakeDaoCurveStrategyWithdraw128"), + stakeDaoCurveStrategyWithdraw256OperatorNameBytes32: toBytes32("stakeDaoCurveStrategyWithdraw256"), + yearnCurveVaultOperator, + yearnVaultStorage, + yearnVaultDepositOperatorNameBytes32: toBytes32("YearnVaultDepositOperator"), + yearnVaultDepositETHOperatorNameBytes32: toBytes32("YearnVaultDepositETHOperator"), + yearnVaultWithdraw128OperatorNameBytes32: toBytes32("YearnVaultWithdraw128Operator"), + yearnVaultWithdraw256OperatorNameBytes32: toBytes32("YearnVaultWithdraw256Operator"), + yearnVaultWithdrawETHOperatorNameBytes32: toBytes32("YearnVaultWithdrawETHOperator"), + yearnVaultAddresses: { + triCryptoVault, + alEthVault, + threeEurVault, + nonWhitelistedVault, + }, + withdrawer, + nestedFactory, + nestedReserve, + masterDeployer, + user1, + proxyAdmin, + baseAmount, + nestedAssetBatcher, + }; +}; diff --git a/test/shared/impersonnate.ts b/test/shared/impersonnate.ts new file mode 100644 index 00000000..83c1b2a2 --- /dev/null +++ b/test/shared/impersonnate.ts @@ -0,0 +1,55 @@ +import { BigNumber, Wallet } from "ethers"; +import { ethers, network } from "hardhat"; + +const abiCoder = new ethers.utils.AbiCoder(); + + +export const impersonnate = async (address: string) => { + await network.provider.request({ + method: "hardhat_impersonateAccount", + params: [address], + }) + + return await ethers.getSigner(address) +} + +export const addBscUsdcBalanceTo = async (receiver: Wallet, amount: BigNumber) => { + const usdcWhale: string = "0xf977814e90da44bfa03b6295a0616a897441acec" + const usdcContract: string = "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d" + const signer = await impersonnate(usdcWhale) + + const data = + ethers.utils.keccak256( + ethers.utils.toUtf8Bytes("transfer(address,uint256)") + ).slice(0, 10) + + abiCoder.encode( + ["address", "uint256"], + [receiver.address, amount] + ).slice(2, 130) + + await signer.sendTransaction({ + from: signer.address, + to: usdcContract, + data: data + }) +} + +export const addEthEurtBalanceTo = async (receiver: Wallet, amount: BigNumber) => { + const eurtWhale: string = "0x5754284f345afc66a98fbb0a0afe71e0f007b949" + const eurtContract: string = "0xC581b735A1688071A1746c968e0798D642EDE491" + const signer = await impersonnate(eurtWhale) + + const data = + ethers.utils.keccak256( + ethers.utils.toUtf8Bytes("transfer(address,uint256)") + ).slice(0, 10) + + abiCoder.encode( + ["address", "uint256"], + [receiver.address, amount] + ).slice(2, 130) + await signer.sendTransaction({ + from: signer.address, + to: eurtContract, + data: data + }) +} \ No newline at end of file diff --git a/test/unit/StakeDaoCurveStrategyOperator.unit.ts b/test/unit/StakeDaoCurveStrategyOperator.unit.ts new file mode 100644 index 00000000..1dc66f1c --- /dev/null +++ b/test/unit/StakeDaoCurveStrategyOperator.unit.ts @@ -0,0 +1,613 @@ +import { expect } from "chai"; +import { createFixtureLoader } from "ethereum-waffle"; +import { BigNumber, utils, Wallet } from "ethers"; +import { ethers } from "hardhat"; +import { cleanResult, getStakeDaoDepositETHOrder, getStakeDaoDepositOrder, getStakeDaoWithdraw128Order, getStakeDaoWithdrawETHOrder, OrderStruct } from "../../scripts/utils"; +import { appendDecimals, BIG_NUMBER_ZERO, getExpectedFees, UINT256_MAX } from "../helpers"; +import { factoryAndOperatorsForkingBSCFixture, FactoryAndOperatorsForkingBSCFixture, factoryAndOperatorsForkingETHFixture, FactoryAndOperatorsForkingETHFixture, USDCBsc } from "../shared/fixtures"; +import { describeOnBscFork, describeOnEthFork, provider } from "../shared/provider"; +import { LoadFixtureFunction } from "../types"; + +let loadFixture: LoadFixtureFunction; + +describeOnBscFork("StakeDaoCurveStrategyOperator BSC fork", () => { + let context: FactoryAndOperatorsForkingBSCFixture; + const ETH = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"; + + before("loader", async () => { + loadFixture = createFixtureLoader(provider.getWallets(), provider); + }); + + beforeEach("create fixture loader", async () => { + context = await loadFixture(factoryAndOperatorsForkingBSCFixture); + }); + + it("deploys and has an address", async () => { + expect(context.stakeDaoCurveStrategyOperator.address).to.be.a.string; + }); + + describe("addStrategy()", () => { + it("Should revert if strategy is address zero", async () => { + const strategyToAdd = ethers.constants.AddressZero; + const poolToAdd = { + poolAddress: Wallet.createRandom().address, + poolCoinAmount: 2, + lpToken: Wallet.createRandom().address + }; + await expect( + context.stakeDaoStrategyStorage.connect(context.masterDeployer).addStrategy(strategyToAdd, poolToAdd), + ).to.be.revertedWith("SDSS: INVALID_STRATEGY_ADDRESS"); + }); + + it("Should revert if pool is address zero", async () => { + const strategyToAdd = Wallet.createRandom().address; + const poolToAdd = { + poolAddress: ethers.constants.AddressZero, + poolCoinAmount: 2, + lpToken: Wallet.createRandom().address + }; + await expect( + context.stakeDaoStrategyStorage.connect(context.masterDeployer).addStrategy(strategyToAdd, poolToAdd), + ).to.be.revertedWith("SDSS: INVALID_POOL_ADDRESS"); + }); + + it("Should revert if already existent strategy", async () => { + const strategyToAdd = Wallet.createRandom().address; + const poolToAdd = { + poolAddress: Wallet.createRandom().address, + poolCoinAmount: 2, + lpToken: Wallet.createRandom().address + }; + await context.stakeDaoStrategyStorage.connect(context.masterDeployer).addStrategy(strategyToAdd, poolToAdd); + + await expect( + context.stakeDaoStrategyStorage.connect(context.masterDeployer).addStrategy(strategyToAdd, poolToAdd), + ).to.be.revertedWith("SDSS: STRATEGY_ALREADY_HAS_POOL"); + }); + + it("Should add new strategy", async () => { + const strategyToAdd = Wallet.createRandom().address; + const poolToAdd = { + poolAddress: Wallet.createRandom().address, + poolCoinAmount: 2, + lpToken: Wallet.createRandom().address + }; + await context.stakeDaoStrategyStorage.connect(context.masterDeployer).addStrategy(strategyToAdd, poolToAdd); + + expect(await (await context.stakeDaoStrategyStorage.strategies(strategyToAdd)).poolAddress).to.equal(poolToAdd.poolAddress); + expect(await (await context.stakeDaoStrategyStorage.strategies(strategyToAdd)).poolCoinAmount).to.equal(poolToAdd.poolCoinAmount); + }); + }); + + describe("removeStrategy()", () => { + it("Should revert if non-existent strategy", async () => { + const strategyToRemove = Wallet.createRandom().address; + + await expect( + context.stakeDaoStrategyStorage.connect(context.masterDeployer).removeStrategy(strategyToRemove), + ).to.be.revertedWith("SDSS: NON_EXISTENT_STRATEGY"); + }); + + it("Should remove strategy", async () => { + const strategyToAdd = Wallet.createRandom().address; + const poolToAdd = { + poolAddress: Wallet.createRandom().address, + poolCoinAmount: 2, + lpToken: Wallet.createRandom().address + }; + await context.stakeDaoStrategyStorage.connect(context.masterDeployer).addStrategy(strategyToAdd, poolToAdd); + + expect(await (await context.stakeDaoStrategyStorage.strategies(strategyToAdd)).poolAddress).to.equal(poolToAdd.poolAddress); + expect(await (await context.stakeDaoStrategyStorage.strategies(strategyToAdd)).poolCoinAmount).to.equal(poolToAdd.poolCoinAmount); + + await context.stakeDaoStrategyStorage.connect(context.masterDeployer).removeStrategy(strategyToAdd); + expect(await (await context.stakeDaoStrategyStorage.strategies(strategyToAdd)).poolAddress).to.equal(ethers.constants.AddressZero); + }); + }); + + describe("deposit()", () => { + it("Should revert if amount to deposit is zero", async () => { + // All the amounts for this test + const bnbToDeposit = appendDecimals(1); + const bnbToDepositAndFees = bnbToDeposit.add(getExpectedFees(bnbToDeposit)); + + // Orders to deposit in StakeDAO with amount 0 + let orders: OrderStruct[] = getStakeDaoDepositOrder(context, context.stakeDaoUsdStrategyAddress, context.WBNB.address, BIG_NUMBER_ZERO); + + // User1 creates the portfolio/NFT + await expect( + context.nestedFactory + .connect(context.user1) + .create(0, [{ inputToken: ETH, amount: bnbToDepositAndFees, orders, fromReserve: false }], { + value: bnbToDepositAndFees, + }), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + + it("Should revert if the strategy is not whitelisted in StakeDAO storage", async () => { + // All the amounts for this test + let usdcToDeposit: BigNumber = appendDecimals(1000); + let usdcToDepositWithFees: BigNumber = usdcToDeposit.add(getExpectedFees(usdcToDeposit)); + let unwhitelistedStrategy: string = Wallet.createRandom().address; + + let orders: OrderStruct[] = getStakeDaoDepositOrder(context, unwhitelistedStrategy, USDCBsc, usdcToDeposit); + + // // User1 creates the portfolio / NFT and submit stakeDAO deposit order + await expect( + context.nestedFactory + .connect(context.user1) + .create(0, [{ inputToken: USDCBsc, amount: usdcToDepositWithFees, orders, fromReserve: false }], { + value: 0, + }), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + + it("Should revert if amount to deposit is greater than available", async () => { + let usdcToDeposit: BigNumber = appendDecimals(1000); + + let orders: OrderStruct[] = getStakeDaoDepositOrder(context, context.stakeDaoUsdStrategyAddress, USDCBsc, usdcToDeposit.mul(2)); + + // User1 creates the portfolio / NFT + await expect( + context.nestedFactory + .connect(context.user1) + .create(0, [{ inputToken: USDCBsc, amount: usdcToDeposit, orders, fromReserve: false }], { + value: 0, + }), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + + it("Should revert if token to deposit is not in the strategy's pool", async () => { + // All the amounts for this test + const bnbToDeposit = appendDecimals(1); + const bnbToDepositAndFees = bnbToDeposit.add(getExpectedFees(bnbToDeposit)); + + // Orders to deposit in beefy with amount 0 + let orders: OrderStruct[] = getStakeDaoDepositOrder(context, context.stakeDaoUsdStrategyAddress, context.WBNB.address, bnbToDeposit); + + // User1 creates the portfolio/NFT + await expect( + context.nestedFactory + .connect(context.user1) + .create(0, [{ inputToken: ETH, amount: bnbToDepositAndFees, orders, fromReserve: false }], { + value: bnbToDepositAndFees, + }), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + + it("Sould revert if the minStrategyToken is not reached", async () => { + // All the amounts for this test + let usdcToDeposit: BigNumber = appendDecimals(1000); + let usdcToDepositWithFees: BigNumber = usdcToDeposit.add(getExpectedFees(usdcToDeposit)); + + let orders: OrderStruct[] = getStakeDaoDepositOrder(context, context.stakeDaoUsdStrategyAddress, USDCBsc, usdcToDeposit, UINT256_MAX); + + // // User1 creates the portfolio / NFT and submit stakeDAO deposit order + await expect( + context.nestedFactory + .connect(context.user1) + .create(0, [{ inputToken: USDCBsc, amount: usdcToDepositWithFees, orders, fromReserve: false }], { + value: 0, + }), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }) + + it("Create/Deposit in StakeDAO with USDC", async () => { + // All the amounts for this test + let usdcToDeposit: BigNumber = appendDecimals(1000); + let usdcToDepositWithFees: BigNumber = usdcToDeposit.add(getExpectedFees(usdcToDeposit)); + + const mockERC20Factory = await ethers.getContractFactory("MockERC20"); + const usdc = mockERC20Factory.attach(USDCBsc); + + let orders: OrderStruct[] = getStakeDaoDepositOrder(context, context.stakeDaoUsdStrategyAddress, USDCBsc, usdcToDeposit); + + const usdcBalanceBefore = await usdc.balanceOf(context.user1.address); + + // // User1 creates the portfolio / NFT and submit stakeDAO deposit order + await context.nestedFactory + .connect(context.user1) + .create(0, [{ inputToken: USDCBsc, amount: usdcToDepositWithFees, orders, fromReserve: false }], { + value: 0, + }) + + + // User1 must be the owner of NFT n°1 + expect(await context.nestedAsset.ownerOf(1)).to.be.equal(context.user1.address); + + const strategy = mockERC20Factory.attach(context.stakeDaoUsdStrategyAddress); + + // StakeDAO strategy tokens user1 balance should be greater than 0 + const strategyTokenBalance = await strategy.balanceOf(context.nestedReserve.address); + expect(strategyTokenBalance).to.be.gt(BIG_NUMBER_ZERO); + + expect(await usdc.balanceOf(context.user1.address)).to.be.equal(usdcBalanceBefore.sub(usdcToDepositWithFees)); + + // The FeeSplitter must receive the right fee amount + expect(await usdc.balanceOf(context.feeSplitter.address)).to.be.equal( + getExpectedFees(usdcToDeposit), + ); + + const expectedNfts = [ + { + id: BigNumber.from(1), + assets: [{ token: context.stakeDaoUsdStrategyAddress, qty: strategyTokenBalance }], + }, + ]; + + const nfts = await context.nestedAssetBatcher.getNfts(context.user1.address); + + expect(JSON.stringify(cleanResult(nfts))).to.equal(JSON.stringify(cleanResult(expectedNfts))); + }); + }); + + describe("withdraw128()", () => { + beforeEach("Create NFT (id 1) with USDC deposited", async () => { + // All the amounts for this test + let usdcToDeposit: BigNumber = appendDecimals(1000); + let usdcToDepositWithFees: BigNumber = usdcToDeposit.add(getExpectedFees(usdcToDeposit)); + + let orders: OrderStruct[] = getStakeDaoDepositOrder(context, context.stakeDaoUsdStrategyAddress, USDCBsc, usdcToDeposit); + + // // User1 creates the portfolio / NFT and submit stakeDAO deposit order + await context.nestedFactory + .connect(context.user1) + .create(0, [{ inputToken: USDCBsc, amount: usdcToDepositWithFees, orders, fromReserve: false }], { + value: 0, + }); + }); + + it("Should revert if amount to withdraw is zero", async () => { + const mockERC20Factory = await ethers.getContractFactory("MockERC20"); + const strategy = mockERC20Factory.attach(context.stakeDaoUsdStrategyAddress); + + const strategyTokenBalance = await strategy.balanceOf(context.nestedReserve.address); + + // Orders to withdraw from stakeDAO + let orders: OrderStruct[] = getStakeDaoWithdraw128Order(context, context.stakeDaoUsdStrategyAddress, BIG_NUMBER_ZERO, USDCBsc); + + await expect( + context.nestedFactory + .connect(context.user1) + .processOutputOrders(1, [ + { outputToken: USDCBsc, amounts: [strategyTokenBalance], orders, toReserve: true }, + ]), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + + it("Should revert if strategy is not whitelisted", async () => { + const mockERC20Factory = await ethers.getContractFactory("MockERC20"); + const strategy = mockERC20Factory.attach(context.stakeDaoNonWhitelistedStrategy); + const strategyTokenBalance = await strategy.balanceOf(context.nestedReserve.address); + + // Orders to withdraw from stakeDAO + let orders: OrderStruct[] = getStakeDaoWithdraw128Order(context, context.stakeDaoNonWhitelistedStrategy, strategyTokenBalance, USDCBsc); + + await expect( + context.nestedFactory + .connect(context.user1) + .processOutputOrders(1, [ + { outputToken: USDCBsc, amounts: [strategyTokenBalance], orders, toReserve: true }, + ]), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + + it("Should revert if amount is greater than available", async () => { + const mockERC20Factory = await ethers.getContractFactory("MockERC20"); + const strategy = mockERC20Factory.attach(context.stakeDaoUsdStrategyAddress); + const strategyTokenBalance = await strategy.balanceOf(context.nestedReserve.address); + + // Orders to withdraw from stakeDAO + let orders: OrderStruct[] = getStakeDaoWithdraw128Order(context, context.stakeDaoUsdStrategyAddress, strategyTokenBalance.mul(2), USDCBsc); + + await expect( + context.nestedFactory + .connect(context.user1) + .processOutputOrders(1, [ + { outputToken: USDCBsc, amounts: [strategyTokenBalance], orders, toReserve: true }, + ]), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + + it("Should revert if output token is not in the curve pool", async () => { + const mockERC20Factory = await ethers.getContractFactory("MockERC20"); + const strategy = mockERC20Factory.attach(context.stakeDaoUsdStrategyAddress); + const strategyTokenBalance = await strategy.balanceOf(context.nestedReserve.address); + + // Orders to withdraw from stakeDAO + let orders: OrderStruct[] = getStakeDaoWithdraw128Order(context, context.stakeDaoUsdStrategyAddress, strategyTokenBalance, context.WBNB.address); + + await expect( + context.nestedFactory + .connect(context.user1) + .processOutputOrders(1, [ + { outputToken: USDCBsc, amounts: [strategyTokenBalance], orders, toReserve: true }, + ]), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + + it("Should revert if minOutputToken is not reached", async () => { + const mockERC20Factory = await ethers.getContractFactory("MockERC20"); + const strategy = mockERC20Factory.attach(context.stakeDaoUsdStrategyAddress); + const strategyTokenBalance = await strategy.balanceOf(context.nestedReserve.address); + + // Orders to withdraw from stakeDAO + let orders: OrderStruct[] = getStakeDaoWithdraw128Order(context, context.stakeDaoUsdStrategyAddress, strategyTokenBalance, USDCBsc, UINT256_MAX); + + await expect( + context.nestedFactory + .connect(context.user1) + .processOutputOrders(1, [ + { outputToken: USDCBsc, amounts: [strategyTokenBalance], orders, toReserve: true }, + ]), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + + it("Destroy/Withdraw from stakeDAO", async () => { + const mockERC20Factory = await ethers.getContractFactory("MockERC20"); + const strategy = mockERC20Factory.attach(context.stakeDaoUsdStrategyAddress); + const usdcContract = mockERC20Factory.attach(USDCBsc); + const strategyTokenBalance = await strategy.balanceOf(context.nestedReserve.address); + + // Orders to withdraw from stakeDAO + let orders: OrderStruct[] = getStakeDaoWithdraw128Order(context, context.stakeDaoUsdStrategyAddress, strategyTokenBalance, USDCBsc); + + await context.nestedFactory.connect(context.user1).destroy(1, USDCBsc, orders); + + // All strategy token removed from reserve + expect(await strategy.balanceOf(context.nestedReserve.address)).to.be.equal(BIG_NUMBER_ZERO); + + /* + * USDC amount received by the FeeSplitter cannot be predicted. + * It should be greater than 0.02 USDC, but sub 1% to allow a margin of error + */ + expect(await usdcContract.balanceOf(context.feeSplitter.address)).to.be.gt( + getExpectedFees(appendDecimals(2)).sub(getExpectedFees(appendDecimals(2)).div(100)), + ); + }); + }); +}); + +describeOnEthFork("StakeDaoCurveStrategyOperator ETH fork", () => { + let context: FactoryAndOperatorsForkingETHFixture; + const ETH = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"; + + before("loader", async () => { + loadFixture = createFixtureLoader(provider.getWallets(), provider); + }); + + beforeEach("create fixture loader", async () => { + context = await loadFixture(factoryAndOperatorsForkingETHFixture); + }); + + it("deploys and has an address", async () => { + expect(context.stakeDaoCurveStrategyOperator.address).to.be.a.string; + }); + + + describe("depositETH()", async () => { + it("Should revert if amount to deposit is zero", async () => { // Done + // All the amounts for this test + const ethToDeposit = appendDecimals(1); + const ethToDepositAndFees = ethToDeposit.add(getExpectedFees(ethToDeposit)); + + // Orders to deposit in stakeDAO with amount 0 + let orders: OrderStruct[] = getStakeDaoDepositETHOrder(context, context.stakeDaoStEthStrategyAddress, BIG_NUMBER_ZERO); + + // User1 creates the portfolio/NFT + await expect( + context.nestedFactory + .connect(context.user1) + .create(0, [{ inputToken: ETH, amount: ethToDepositAndFees, orders, fromReserve: false }], { + value: ethToDepositAndFees, + }), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + + it("Should revert if deposit more than available", async () => { // Done, fail at withdrawer + // All the amounts for this test + const ethToDeposit = appendDecimals(1); + const ethToDepositAndFees = ethToDeposit.add(getExpectedFees(ethToDeposit)); + + // Orders to deposit in stakeDAO with "initial amount x 2" (more than msg.value) + let orders: OrderStruct[] = getStakeDaoDepositETHOrder(context, context.stakeDaoStEthStrategyAddress, ethToDepositAndFees.mul(2)); + + // User1 creates the portfolio/NFT + await expect( + context.nestedFactory + .connect(context.user1) + .create(0, [{ inputToken: ETH, amount: ethToDepositAndFees, orders, fromReserve: false }], { + value: ethToDepositAndFees, + }), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + + it("Should revert if strategy is not added in Operator Storage", async () => { // Done + // All the amounts for this test + const ethToDeposit = appendDecimals(1); + const ethToDepositAndFees = ethToDeposit.add(getExpectedFees(ethToDeposit)); + + // Orders to Deposit in stakeDAO + let orders: OrderStruct[] = getStakeDaoDepositETHOrder(context, context.stakeDaoNonWhitelistedStrategy, ethToDeposit); + + // User1 creates the portfolio/NFT + await expect( + context.nestedFactory + .connect(context.user1) + .create(0, [{ inputToken: ETH, amount: ethToDepositAndFees, orders, fromReserve: false }], { + value: ethToDepositAndFees, + }), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + + it("Should revert if minstrategyAmount is not respected", async () => { // Done + // All the amounts for this test + const ethToDeposit = appendDecimals(1); + const ethToDepositAndFees = ethToDeposit.add(getExpectedFees(ethToDeposit)); + + // Orders to Deposit in stakeDAO + let orders: OrderStruct[] = getStakeDaoDepositETHOrder(context, context.stakeDaoStEthStrategyAddress, ethToDeposit, UINT256_MAX); + + // User1 creates the portfolio/NFT + await expect( + context.nestedFactory + .connect(context.user1) + .create(0, [{ inputToken: ETH, amount: ethToDepositAndFees, orders, fromReserve: false }], { + value: ethToDepositAndFees, + }), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + + it("Create/Deposit in stakeDAO stETH with ETH", async () => { + // All the amounts for this test + const ethToDeposit = appendDecimals(1); + const ethToDepositAndFees = ethToDeposit.add(getExpectedFees(ethToDeposit)); + + const ethBalanceBefore = await context.user1.getBalance(); + + // Orders to Deposit in stakeDAO + let orders: OrderStruct[] = getStakeDaoDepositETHOrder(context, context.stakeDaoStEthStrategyAddress, ethToDeposit); + + // User1 creates the portfolio/NFT + const tx = await context.nestedFactory + .connect(context.user1) + .create(0, [{ inputToken: ETH, amount: ethToDepositAndFees, orders, fromReserve: false }], { + value: ethToDepositAndFees, + }); + + // Get the transaction fees + const txFees = await tx.wait().then(value => value.gasUsed.mul(value.effectiveGasPrice)); + + // User1 must be the owner of NFT n°1 + expect(await context.nestedAsset.ownerOf(1)).to.be.equal(context.user1.address); + + const mockERC20Factory = await ethers.getContractFactory("MockERC20"); + const strategy = mockERC20Factory.attach(context.stakeDaoStEthStrategyAddress); + + // Tokens in strategy + const strategyBalanceReserve = await strategy.balanceOf(context.nestedReserve.address); + expect(strategyBalanceReserve).to.not.be.equal(BIG_NUMBER_ZERO); + + expect(await context.user1.getBalance()).to.be.equal(ethBalanceBefore.sub(ethToDepositAndFees).sub(txFees)); + + // The FeeSplitter must receive the right fee amount + expect(await context.WETH.balanceOf(context.feeSplitter.address)).to.be.equal( + getExpectedFees(ethToDeposit), + ); + + const expectedNfts = [ + { + id: BigNumber.from(1), + assets: [{ token: context.stakeDaoStEthStrategyAddress, qty: strategyBalanceReserve }], + }, + ]; + + const nfts = await context.nestedAssetBatcher.getNfts(context.user1.address); + + expect(JSON.stringify(cleanResult(nfts))).to.equal(JSON.stringify(cleanResult(expectedNfts))); + }); + }) + + describe("withdrawETH()", () => { + beforeEach("Create NFT (id 1) with ETH deposited", async () => { + // All the amounts for this test + const ethToDeposit = appendDecimals(1); + const ethToDepositAndFees = ethToDeposit.add(getExpectedFees(ethToDeposit)); + + // Orders to Deposit in stakeDAO + let orders: OrderStruct[] = getStakeDaoDepositETHOrder(context, context.stakeDaoStEthStrategyAddress, ethToDeposit); + + // User1 creates the portfolio/NFT + const tx = await context.nestedFactory + .connect(context.user1) + .create(0, [{ inputToken: ETH, amount: ethToDepositAndFees, orders, fromReserve: false }], { + value: ethToDepositAndFees, + }); + }); + it("Should revert if amount to withdraw is zero", async () => { // Done + // Orders to withdraw from StakeDAO curve strategy + let orders: OrderStruct[] = getStakeDaoWithdrawETHOrder(context, context.stakeDaoStEthStrategyAddress, BIG_NUMBER_ZERO); + + // User1 creates the portfolio/NFT + await expect( + context.nestedFactory + .connect(context.user1) + .processOutputOrders(1, [ + { outputToken: context.WETH.address, amounts: [BIG_NUMBER_ZERO], orders, toReserve: true }, + ]), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + it("Should revert if withdraw more than available", async () => { // Done + const mockERC20Factory = await ethers.getContractFactory("MockERC20"); + const strategy = mockERC20Factory.attach(context.stakeDaoStEthStrategyAddress); + + const strategyBalance = await strategy.balanceOf(context.nestedReserve.address); + + // Orders to withdraw from StakeDAO curve strategy + let orders: OrderStruct[] = getStakeDaoWithdrawETHOrder(context, context.stakeDaoStEthStrategyAddress, strategyBalance.mul(2)); + + // User1 creates the portfolio/NFT + await expect( + context.nestedFactory + .connect(context.user1) + .processOutputOrders(1, [ + { outputToken: context.WETH.address, amounts: [strategyBalance], orders, toReserve: true }, + ]), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + it("Should revert if strategy is not added in Operator Storage", async () => { // Done + // Orders to withdraw from StakeDAO curve strategy + let orders: OrderStruct[] = getStakeDaoWithdrawETHOrder(context, context.stakeDaoNonWhitelistedStrategy, BigNumber.from(100)); + + // User1 creates the portfolio/NFT + await expect( + context.nestedFactory + .connect(context.user1) + .processOutputOrders(1, [ + { outputToken: context.WETH.address, amounts: [BIG_NUMBER_ZERO], orders, toReserve: true }, + ]), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + it("Should revert if minAmountOut is not respected", async () => { // Done + const mockERC20Factory = await ethers.getContractFactory("MockERC20"); + const strategy = mockERC20Factory.attach(context.stakeDaoStEthStrategyAddress); + + const strategyBalance = await strategy.balanceOf(context.nestedReserve.address); + + // Orders to withdraw from StakeDAO strategy and remove liquidity from curve pool + let orders: OrderStruct[] = getStakeDaoWithdrawETHOrder(context, context.stakeDaoStEthStrategyAddress, strategyBalance, UINT256_MAX); + + // User1 creates the portfolio/NFT + await expect( + context.nestedFactory + .connect(context.user1) + .processOutputOrders(1, [ + { outputToken: context.WETH.address, amounts: [strategyBalance], orders, toReserve: true }, + ]), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + it("Destroy/Withdraw from StakeDAO", async () => { + const mockERC20Factory = await ethers.getContractFactory("MockERC20"); + const strategy = mockERC20Factory.attach(context.stakeDaoStEthStrategyAddress); + + const strategyBalance = await strategy.balanceOf(context.nestedReserve.address); + + // Orders to withdraw from StakeDAO curve strategy + let orders: OrderStruct[] = getStakeDaoWithdrawETHOrder(context, context.stakeDaoStEthStrategyAddress, strategyBalance); + + await context.nestedFactory.connect(context.user1).destroy(1, context.WETH.address, orders); + + // All StakeDAO strategy token removed from reserve + expect(await strategy.balanceOf(context.nestedReserve.address)).to.be.equal(BIG_NUMBER_ZERO); + expect(await strategy.balanceOf(context.nestedFactory.address)).to.be.equal(BIG_NUMBER_ZERO); + + /* + * WETH received by the FeeSplitter cannot be predicted. + * It should be greater than 0.01 WETH, but sub 1% to allow a margin of error + */ + expect(await context.WETH.balanceOf(context.feeSplitter.address)).to.be.gt( + getExpectedFees(appendDecimals(1)).sub(getExpectedFees(appendDecimals(1)).div(100)), + ); + }); + }); +}); diff --git a/test/unit/YearnCurveVaultOperator.unit.ts b/test/unit/YearnCurveVaultOperator.unit.ts new file mode 100644 index 00000000..00619404 --- /dev/null +++ b/test/unit/YearnCurveVaultOperator.unit.ts @@ -0,0 +1,721 @@ +import { LoadFixtureFunction } from "../types"; +import { EURTEth, FactoryAndOperatorsForkingETHFixture, factoryAndOperatorsForkingETHFixture, USDCEth } from "../shared/fixtures"; +import { createFixtureLoader, describeOnEthFork, expect, provider } from "../shared/provider"; +import { BigNumber, Wallet } from "ethers"; +import { append6Decimals, appendDecimals, BIG_NUMBER_ZERO, getExpectedFees, UINT256_MAX } from "../helpers"; +import * as utils from "../../scripts/utils"; +import { ethers } from "hardhat"; + +let loadFixture: LoadFixtureFunction; + +describeOnEthFork("YearnCurveVaultOperator", () => { + let context: FactoryAndOperatorsForkingETHFixture; + const ETH = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"; + + before("loader", async () => { + loadFixture = createFixtureLoader(provider.getWallets(), provider); + }); + + beforeEach("create fixture loader", async () => { + context = await loadFixture(factoryAndOperatorsForkingETHFixture); + }); + + it("deploys and has an address", async () => { + expect(context.yearnCurveVaultOperator.address).to.be.a.string; + }); + + describe("addVault()", () => { + it("Should revert if vault is address zero", async () => { + const vaultToAdd = ethers.constants.AddressZero; + const poolToAdd = Wallet.createRandom().address; + await expect( + context.yearnVaultStorage.connect(context.masterDeployer).addVault(vaultToAdd, { + poolAddress: poolToAdd, + poolCoinAmount: 1, + lpToken: poolToAdd + }), + ).to.be.revertedWith("YVS: INVALID_VAULT_ADDRESS"); + }); + + it("Should revert if already existent vault", async () => { + const vaultToAdd = Wallet.createRandom().address; + const poolToAdd = Wallet.createRandom().address; + await context.yearnVaultStorage.connect(context.masterDeployer).addVault(vaultToAdd, { + poolAddress: poolToAdd, + poolCoinAmount: 1, + lpToken: poolToAdd + }); + + await expect( + context.yearnVaultStorage.connect(context.masterDeployer).addVault(vaultToAdd, { + poolAddress: poolToAdd, + poolCoinAmount: 1, + lpToken: poolToAdd + }), + ).to.be.revertedWith("YVS: VAULT_ALREADY_HAS_POOL"); + }); + + it("Should add new vault", async () => { + const vaultToAdd = Wallet.createRandom().address; + const poolToAdd = Wallet.createRandom().address; + const poolCoinAmount = 1; + await context.yearnVaultStorage.connect(context.masterDeployer).addVault(vaultToAdd, { + poolAddress: poolToAdd, + poolCoinAmount, + lpToken: poolToAdd + }); + + expect(await (await context.yearnVaultStorage.vaults(vaultToAdd)).poolAddress).to.equal(poolToAdd); + expect(await (await context.yearnVaultStorage.vaults(vaultToAdd)).poolCoinAmount).to.equal(poolCoinAmount); + + }); + }); + + describe("removeVault()", () => { + it("Should revert if non-existent vault", async () => { + const vaultToRemove = Wallet.createRandom().address; + + await expect( + context.yearnVaultStorage.connect(context.masterDeployer).removeVault(vaultToRemove), + ).to.be.revertedWith("YVS: NON_EXISTENT_VAULT"); + }); + + it("Should remove vault", async () => { + const vaultToAdd = Wallet.createRandom().address; + const poolToAdd = Wallet.createRandom().address; + await context.yearnVaultStorage.connect(context.masterDeployer).addVault(vaultToAdd, { + poolAddress: poolToAdd, + poolCoinAmount: 1, + lpToken: poolToAdd + }); + + await context.yearnVaultStorage.connect(context.masterDeployer).removeVault(vaultToAdd); + + expect(await (await context.yearnVaultStorage.vaults(vaultToAdd)).poolAddress).to.equal(ethers.constants.AddressZero); + }); + }); + + describe("deposit()", () => { + it("Should revert if amount to deposit is zero", async () => { + // All the amounts for this test + const ethToDeposit = appendDecimals(1); + const ethToDepositAndFees = ethToDeposit.add(getExpectedFees(ethToDeposit)); + + // Orders to deposit in yearn with amount 0 + let orders: utils.OrderStruct[] = utils.getYearnCurveDepositOrder(context, context.yearnVaultAddresses.triCryptoVault, context.WETH.address, BIG_NUMBER_ZERO); + + // User1 creates the portfolio/NFT + await expect( + context.nestedFactory + .connect(context.user1) + .create(0, [{ inputToken: ETH, amount: ethToDepositAndFees, orders, fromReserve: false }], { + value: ethToDepositAndFees, + }), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + + it("Should revert if deposit more than available", async () => { + // All the amounts for this test + const ethToDeposit = appendDecimals(1); + const ethToDepositAndFees = ethToDeposit.add(getExpectedFees(ethToDeposit)); + + // Orders to deposit in yearn with "initial amount x 2" (more than msg.value) + let orders: utils.OrderStruct[] = utils.getYearnCurveDepositOrder(context, context.yearnVaultAddresses.triCryptoVault, context.WETH.address, ethToDepositAndFees.mul(2)); + + // User1 creates the portfolio/NFT + await expect( + context.nestedFactory + .connect(context.user1) + .create(0, [{ inputToken: ETH, amount: ethToDepositAndFees, orders, fromReserve: false }], { + value: ethToDepositAndFees, + }), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + + it("Should revert if vault is not added in Operator Storage", async () => { + // All the amounts for this test + const ethToDeposit = appendDecimals(1); + const ethToDepositAndFees = ethToDeposit.add(getExpectedFees(ethToDeposit)); + + const notWhitelistedVault = Wallet.createRandom().address; + + // Orders to Deposit in yearn + let orders: utils.OrderStruct[] = utils.getYearnCurveDepositOrder(context, notWhitelistedVault, context.WETH.address, ethToDeposit); + + // User1 creates the portfolio/NFT + await expect( + context.nestedFactory + .connect(context.user1) + .create(0, [{ inputToken: ETH, amount: ethToDepositAndFees, orders, fromReserve: false }], { + value: ethToDepositAndFees, + }), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + + it("Should revert if token is not in Curve pool", async () => { + // All the amounts for this test + const ethToDeposit = appendDecimals(1); + const ethToDepositAndFees = ethToDeposit.add(getExpectedFees(ethToDeposit)); + + // Orders to Deposit in yearn + let orders: utils.OrderStruct[] = utils.getYearnCurveDepositOrder(context, context.yearnVaultAddresses.triCryptoVault, USDCEth, ethToDeposit); + + // User1 creates the portfolio/NFT + await expect( + context.nestedFactory + .connect(context.user1) + .create(0, [{ inputToken: ETH, amount: ethToDepositAndFees, orders, fromReserve: false }], { + value: ethToDepositAndFees, + }), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + + it("Should revert if minVaultAmount is not respected", async () => { + // All the amounts for this test + const ethToDeposit = appendDecimals(1); + const ethToDepositAndFees = ethToDeposit.add(getExpectedFees(ethToDeposit)); + + // Orders to Deposit in yearn + let orders: utils.OrderStruct[] = utils.getYearnCurveDepositOrder(context, context.yearnVaultAddresses.triCryptoVault, context.WETH.address, ethToDeposit, UINT256_MAX); + + // User1 creates the portfolio/NFT + await expect( + context.nestedFactory + .connect(context.user1) + .create(0, [{ inputToken: ETH, amount: ethToDepositAndFees, orders, fromReserve: false }], { + value: ethToDepositAndFees, + }), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + + it("Create with ETH and Deposit in yearn 3crypto with WETH", async () => { + // All the amounts for this test + const ethToDeposit = appendDecimals(1); + const ethToDepositAndFees = ethToDeposit.add(getExpectedFees(ethToDeposit)); + + const ethBalanceBefore = await context.user1.getBalance(); + + // Orders to Deposit in yearn + let orders: utils.OrderStruct[] = utils.getYearnCurveDepositOrder(context, context.yearnVaultAddresses.triCryptoVault, context.WETH.address, ethToDeposit); + + // User1 creates the portfolio/NFT + const tx = await context.nestedFactory + .connect(context.user1) + .create(0, [{ inputToken: ETH, amount: ethToDepositAndFees, orders, fromReserve: false }], { + value: ethToDepositAndFees, + }); + + // Get the transaction fees + const txFees = await tx.wait().then(value => value.gasUsed.mul(value.effectiveGasPrice)); + + // User1 must be the owner of NFT n°1 + expect(await context.nestedAsset.ownerOf(1)).to.be.equal(context.user1.address); + + const mockERC20Factory = await ethers.getContractFactory("MockERC20"); + const vault = mockERC20Factory.attach(context.yearnVaultAddresses.triCryptoVault); + + // Tokens in vault + const vaultBalanceReserve = await vault.balanceOf(context.nestedReserve.address); + expect(vaultBalanceReserve).to.not.be.equal(BIG_NUMBER_ZERO); + + expect(await context.user1.getBalance()).to.be.equal(ethBalanceBefore.sub(ethToDepositAndFees).sub(txFees)); + + // The FeeSplitter must receive the right fee amount + expect(await context.WETH.balanceOf(context.feeSplitter.address)).to.be.equal( + getExpectedFees(ethToDeposit), + ); + + const expectedNfts = [ + { + id: BigNumber.from(1), + assets: [{ token: context.yearnVaultAddresses.triCryptoVault, qty: vaultBalanceReserve }], + }, + ]; + + const nfts = await context.nestedAssetBatcher.getNfts(context.user1.address); + + expect(JSON.stringify(utils.cleanResult(nfts))).to.equal(JSON.stringify(utils.cleanResult(expectedNfts))); + }); + }); + + describe("depositETH()", async () => { + it("Should revert if amount to deposit is zero", async () => { + // All the amounts for this test + const ethToDeposit = appendDecimals(1); + const ethToDepositAndFees = ethToDeposit.add(getExpectedFees(ethToDeposit)); + + // Orders to deposit in yearn with amount 0 + let orders: utils.OrderStruct[] = utils.getYearnCurveDepositETHOrder(context, context.yearnVaultAddresses.alEthVault, BIG_NUMBER_ZERO); + + // User1 creates the portfolio/NFT + await expect( + context.nestedFactory + .connect(context.user1) + .create(0, [{ inputToken: ETH, amount: ethToDepositAndFees, orders, fromReserve: false }], { + value: ethToDepositAndFees, + }), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + it("Should revert if deposit more than available", async () => { + // All the amounts for this test + const ethToDeposit = appendDecimals(1); + const ethToDepositAndFees = ethToDeposit.add(getExpectedFees(ethToDeposit)); + + // Orders to deposit in yearn with "initial amount x 2" (more than msg.value) + let orders: utils.OrderStruct[] = utils.getYearnCurveDepositETHOrder(context, context.yearnVaultAddresses.alEthVault, ethToDepositAndFees.mul(2)); + + // User1 creates the portfolio/NFT + await expect( + context.nestedFactory + .connect(context.user1) + .create(0, [{ inputToken: ETH, amount: ethToDepositAndFees, orders, fromReserve: false }], { + value: ethToDepositAndFees, + }), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + it("Should revert if vault is not added in Operator Storage", async () => { + // All the amounts for this test + const ethToDeposit = appendDecimals(1); + const ethToDepositAndFees = ethToDeposit.add(getExpectedFees(ethToDeposit)); + + const notWhitelistedVault = Wallet.createRandom().address; + + // Orders to Deposit in yearn + let orders: utils.OrderStruct[] = utils.getYearnCurveDepositETHOrder(context, notWhitelistedVault, ethToDeposit); + + // User1 creates the portfolio/NFT + await expect( + context.nestedFactory + .connect(context.user1) + .create(0, [{ inputToken: ETH, amount: ethToDepositAndFees, orders, fromReserve: false }], { + value: ethToDepositAndFees, + }), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + it("Should revert if minVaultAmount is not respected", async () => { + // All the amounts for this test + const ethToDeposit = appendDecimals(1); + const ethToDepositAndFees = ethToDeposit.add(getExpectedFees(ethToDeposit)); + + // Orders to Deposit in yearn + let orders: utils.OrderStruct[] = utils.getYearnCurveDepositETHOrder(context, context.yearnVaultAddresses.alEthVault, ethToDeposit, UINT256_MAX); + + // User1 creates the portfolio/NFT + await expect( + context.nestedFactory + .connect(context.user1) + .create(0, [{ inputToken: ETH, amount: ethToDepositAndFees, orders, fromReserve: false }], { + value: ethToDepositAndFees, + }), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + it("Create/Deposit in yearn alETH with ETH", async () => { + // All the amounts for this test + const ethToDeposit = appendDecimals(1); + const ethToDepositAndFees = ethToDeposit.add(getExpectedFees(ethToDeposit)); + + const ethBalanceBefore = await context.user1.getBalance(); + + // Orders to Deposit in yearn + let orders: utils.OrderStruct[] = utils.getYearnCurveDepositETHOrder(context, context.yearnVaultAddresses.alEthVault, ethToDeposit); + + // User1 creates the portfolio/NFT + const tx = await context.nestedFactory + .connect(context.user1) + .create(0, [{ inputToken: ETH, amount: ethToDepositAndFees, orders, fromReserve: false }], { + value: ethToDepositAndFees, + }); + + // Get the transaction fees + const txFees = await tx.wait().then(value => value.gasUsed.mul(value.effectiveGasPrice)); + + // User1 must be the owner of NFT n°1 + expect(await context.nestedAsset.ownerOf(1)).to.be.equal(context.user1.address); + + const mockERC20Factory = await ethers.getContractFactory("MockERC20"); + const vault = mockERC20Factory.attach(context.yearnVaultAddresses.alEthVault); + + // Tokens in vault + const vaultBalanceReserve = await vault.balanceOf(context.nestedReserve.address); + expect(vaultBalanceReserve).to.not.be.equal(BIG_NUMBER_ZERO); + + expect(await context.user1.getBalance()).to.be.equal(ethBalanceBefore.sub(ethToDepositAndFees).sub(txFees)); + + // The FeeSplitter must receive the right fee amount + expect(await context.WETH.balanceOf(context.feeSplitter.address)).to.be.equal( + getExpectedFees(ethToDeposit), + ); + + const expectedNfts = [ + { + id: BigNumber.from(1), + assets: [{ token: context.yearnVaultAddresses.alEthVault, qty: vaultBalanceReserve }], + }, + ]; + + const nfts = await context.nestedAssetBatcher.getNfts(context.user1.address); + + expect(JSON.stringify(utils.cleanResult(nfts))).to.equal(JSON.stringify(utils.cleanResult(expectedNfts))); + }); + }) + + describe("withdraw256()", () => { + beforeEach("Create NFT (id 1) with ETH deposited", async () => { + // All the amounts for this test + const ethToDeposit = appendDecimals(1); + const ethToDepositAndFees = ethToDeposit.add(getExpectedFees(ethToDeposit)); + + // Orders to Deposit in yearn + let orders: utils.OrderStruct[] = utils.getYearnCurveDepositOrder(context, context.yearnVaultAddresses.triCryptoVault, context.WETH.address, ethToDeposit); + + // User1 creates the portfolio/NFT + const tx = await context.nestedFactory + .connect(context.user1) + .create(0, [{ inputToken: ETH, amount: ethToDepositAndFees, orders, fromReserve: false }], { + value: ethToDepositAndFees, + }); + + }); + it("Should revert if amount to withdraw is zero", async () => { + const mockERC20Factory = await ethers.getContractFactory("MockERC20"); + const vault = mockERC20Factory.attach(context.yearnVaultAddresses.triCryptoVault); + + const vaultBalance = await vault.balanceOf(context.nestedReserve.address); + + // Orders to withdraw from yearn curve vault + let orders: utils.OrderStruct[] = utils.getYearnCurveWithdraw256Order(context, context.yearnVaultAddresses.triCryptoVault, BIG_NUMBER_ZERO, context.WETH.address); + + // User1 creates the portfolio/NFT + await expect( + context.nestedFactory + .connect(context.user1) + .processOutputOrders(1, [ + { outputToken: context.WETH.address, amounts: [vaultBalance], orders, toReserve: true }, + ]), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + it("Should revert if withdraw more than available", async () => { + const mockERC20Factory = await ethers.getContractFactory("MockERC20"); + const vault = mockERC20Factory.attach(context.yearnVaultAddresses.triCryptoVault); + + const vaultBalance = await vault.balanceOf(context.nestedReserve.address); + + // Orders to withdraw from yearn curve vault + let orders: utils.OrderStruct[] = utils.getYearnCurveWithdraw256Order(context, context.yearnVaultAddresses.triCryptoVault, vaultBalance.mul(2), context.WETH.address); + + // User1 creates the portfolio/NFT + await expect( + context.nestedFactory + .connect(context.user1) + .processOutputOrders(1, [ + { outputToken: context.WETH.address, amounts: [vaultBalance], orders, toReserve: true }, + ]), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + it("Should revert if vault is not added in Operator Storage", async () => { + // Orders to withdraw from yearn curve vault + let orders: utils.OrderStruct[] = utils.getYearnCurveWithdraw256Order(context, context.yearnVaultAddresses.nonWhitelistedVault, BigNumber.from(100), context.WETH.address); + + // User1 creates the portfolio/NFT + await expect( + context.nestedFactory + .connect(context.user1) + .processOutputOrders(1, [ + { outputToken: context.WETH.address, amounts: [BIG_NUMBER_ZERO], orders, toReserve: true }, + ]), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + it("Should revert if token is not in Curve pool", async () => { + const mockERC20Factory = await ethers.getContractFactory("MockERC20"); + const vault = mockERC20Factory.attach(context.yearnVaultAddresses.triCryptoVault); + + const vaultBalance = await vault.balanceOf(context.nestedReserve.address); + + // Orders to withdraw from yearn vault and remove liquidity from curve pool + let orders: utils.OrderStruct[] = utils.getYearnCurveWithdraw256Order(context, context.yearnVaultAddresses.triCryptoVault, vaultBalance, USDCEth); // if you pass a no ERC20 address it will return a random fail + + // User1 creates the portfolio/NFT + await expect( + context.nestedFactory + .connect(context.user1) + .processOutputOrders(1, [ + { outputToken: context.WETH.address, amounts: [vaultBalance], orders, toReserve: true }, + ]), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + it("Should revert if minAmountOut is not respected", async () => { + const mockERC20Factory = await ethers.getContractFactory("MockERC20"); + const vault = mockERC20Factory.attach(context.yearnVaultAddresses.triCryptoVault); + + const vaultBalance = await vault.balanceOf(context.nestedReserve.address); + + // Orders to withdraw from yearn vault and remove liquidity from curve pool + let orders: utils.OrderStruct[] = utils.getYearnCurveWithdraw256Order(context, context.yearnVaultAddresses.triCryptoVault, vaultBalance, context.WETH.address, UINT256_MAX); + + // User1 creates the portfolio/NFT + await expect( + context.nestedFactory + .connect(context.user1) + .processOutputOrders(1, [ + { outputToken: context.WETH.address, amounts: [vaultBalance], orders, toReserve: true }, + ]), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + it("Destroy/Withdraw from Yearn", async () => { + const mockERC20Factory = await ethers.getContractFactory("MockERC20"); + const vault = mockERC20Factory.attach(context.yearnVaultAddresses.triCryptoVault); + + const vaultBalance = await vault.balanceOf(context.nestedReserve.address); + + // Orders to withdraw from yearn curve vault + let orders: utils.OrderStruct[] = utils.getYearnCurveWithdraw256Order(context, context.yearnVaultAddresses.triCryptoVault, vaultBalance, context.WETH.address); + + await context.nestedFactory.connect(context.user1).destroy(1, context.WETH.address, orders); + + // All yearn vault token removed from reserve + expect(await vault.balanceOf(context.nestedReserve.address)).to.be.equal(BIG_NUMBER_ZERO); + expect(await vault.balanceOf(context.nestedFactory.address)).to.be.equal(BIG_NUMBER_ZERO); + + /* + * WETH received by the FeeSplitter cannot be predicted. + * It should be greater than 0.01 WETH, but sub 1% to allow a margin of error + */ + expect(await context.WETH.balanceOf(context.feeSplitter.address)).to.be.gt( + getExpectedFees(appendDecimals(1)).sub(getExpectedFees(appendDecimals(1)).div(100)), + ); + }); + }); + + describe("withdraw128()", () => { + beforeEach("Create NFT (id 1) with eurt deposited", async () => { + // All the amounts for this test + const eurtToDeposit = append6Decimals(1000); + const eurtToDepositAndFees = eurtToDeposit.add(getExpectedFees(eurtToDeposit)); + + // Orders to Deposit in yearn + let orders: utils.OrderStruct[] = utils.getYearnCurveDepositOrder(context, context.yearnVaultAddresses.threeEurVault, EURTEth, eurtToDeposit); + + // User1 creates the portfolio/NFT + await context.nestedFactory + .connect(context.user1) + .create(0, [{ inputToken: EURTEth, amount: eurtToDepositAndFees, orders, fromReserve: false }], { + value: 0, + }); + }); + it("Should revert if amount to withdraw is zero", async () => { + const mockERC20Factory = await ethers.getContractFactory("MockERC20"); + const vault = mockERC20Factory.attach(context.yearnVaultAddresses.threeEurVault); + + const vaultBalance = await vault.balanceOf(context.nestedReserve.address); + + // Orders to withdraw from yearn vault and remove liquidity from curve pool + let orders: utils.OrderStruct[] = utils.getYearnCurveWithdraw128Order(context, context.yearnVaultAddresses.threeEurVault, BIG_NUMBER_ZERO, EURTEth); + + // User1 creates the portfolio/NFT + await expect( + context.nestedFactory + .connect(context.user1) + .processOutputOrders(1, [ + { outputToken: EURTEth, amounts: [vaultBalance], orders, toReserve: true }, + ]), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + it("Should revert if withdraw more than available", async () => { + const mockERC20Factory = await ethers.getContractFactory("MockERC20"); + const vault = mockERC20Factory.attach(context.yearnVaultAddresses.threeEurVault); + + const vaultBalance = await vault.balanceOf(context.nestedReserve.address); + + // Orders to withdraw from yearn vault and remove liquidity from curve pool + let orders: utils.OrderStruct[] = utils.getYearnCurveWithdraw128Order(context, context.yearnVaultAddresses.threeEurVault, vaultBalance.mul(2), EURTEth); + + // User1 creates the portfolio/NFT + await expect( + context.nestedFactory + .connect(context.user1) + .processOutputOrders(1, [ + { outputToken: EURTEth, amounts: [vaultBalance], orders, toReserve: true }, + ]), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + it("Should revert if vault is not added in Operator Storage", async () => { + // Orders to withdraw from yearn curve vault + let orders: utils.OrderStruct[] = utils.getYearnCurveWithdraw128Order(context, context.yearnVaultAddresses.nonWhitelistedVault, BigNumber.from(100), EURTEth); + + // User1 creates the portfolio/NFT + await expect( + context.nestedFactory + .connect(context.user1) + .processOutputOrders(1, [ + { outputToken: EURTEth, amounts: [BIG_NUMBER_ZERO], orders, toReserve: true }, + ]), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + it("Should revert if token is not in Curve pool", async () => { + const mockERC20Factory = await ethers.getContractFactory("MockERC20"); + const vault = mockERC20Factory.attach(context.yearnVaultAddresses.threeEurVault); + + const vaultBalance = await vault.balanceOf(context.nestedReserve.address); + + // Orders to withdraw from yearn vault and remove liquidity from curve pool + let orders: utils.OrderStruct[] = utils.getYearnCurveWithdraw128Order(context, context.yearnVaultAddresses.threeEurVault, vaultBalance, USDCEth);// if you pass a no ERC20 address it will return a random fail + + // User1 creates the portfolio/NFT + await expect( + context.nestedFactory + .connect(context.user1) + .processOutputOrders(1, [ + { outputToken: EURTEth, amounts: [vaultBalance], orders, toReserve: true }, + ]), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + it("Should revert if minAmountOut is not respected", async () => { + const mockERC20Factory = await ethers.getContractFactory("MockERC20"); + const vault = mockERC20Factory.attach(context.yearnVaultAddresses.threeEurVault); + + const vaultBalance = await vault.balanceOf(context.nestedReserve.address); + + // Orders to withdraw from yearn vault and remove liquidity from curve pool + let orders: utils.OrderStruct[] = utils.getYearnCurveWithdraw128Order(context, context.yearnVaultAddresses.threeEurVault, vaultBalance, context.WETH.address, UINT256_MAX); + + // User1 creates the portfolio/NFT + await expect( + context.nestedFactory + .connect(context.user1) + .processOutputOrders(1, [ + { outputToken: EURTEth, amounts: [vaultBalance], orders, toReserve: true }, + ]), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + it("Destroy/Withdraw with EURT from Yearn", async () => { + const mockERC20Factory = await ethers.getContractFactory("MockERC20"); + const vault = mockERC20Factory.attach(context.yearnVaultAddresses.threeEurVault); + + const eurt = mockERC20Factory.attach(EURTEth); + + const vaultBalance = await vault.balanceOf(context.nestedReserve.address); + + // Orders to withdraw from yearn curve vault + let orders: utils.OrderStruct[] = utils.getYearnCurveWithdraw128Order(context, context.yearnVaultAddresses.threeEurVault, vaultBalance, EURTEth); + + await context.nestedFactory.connect(context.user1).destroy(1, EURTEth, orders); + + // All yearn vault token removed from reserve + expect(await vault.balanceOf(context.nestedReserve.address)).to.be.equal(BIG_NUMBER_ZERO); + expect(await vault.balanceOf(context.nestedFactory.address)).to.be.equal(BIG_NUMBER_ZERO); + + /* + * WETH received by the FeeSplitter cannot be predicted. + * It should be greater than 0.01 WETH, but sub 1% to allow a margin of error + */ + expect(await eurt.balanceOf(context.feeSplitter.address)).to.be.gt( + getExpectedFees(append6Decimals(1)).sub(getExpectedFees(append6Decimals(1)).div(100)), + ); + + expect(await (await eurt.balanceOf(context.user1.address))).to.be.gt(BIG_NUMBER_ZERO); + }); + }); + + describe("withdrawETH()", () => { + beforeEach("Create NFT (id 1) with ETH deposited", async () => { + // All the amounts for this test + const ethToDeposit = appendDecimals(1); + const ethToDepositAndFees = ethToDeposit.add(getExpectedFees(ethToDeposit)); + + // Orders to Deposit in yearn + let orders: utils.OrderStruct[] = utils.getYearnCurveDepositETHOrder(context, context.yearnVaultAddresses.alEthVault, ethToDeposit); + + // User1 creates the portfolio/NFT + const tx = await context.nestedFactory + .connect(context.user1) + .create(0, [{ inputToken: ETH, amount: ethToDepositAndFees, orders, fromReserve: false }], { + value: ethToDepositAndFees, + }); + }); + it("Should revert if amount to withdraw is zero", async () => { + // Orders to withdraw from yearn curve vault + let orders: utils.OrderStruct[] = utils.getYearnCurveWithdrawETHOrder(context, context.yearnVaultAddresses.alEthVault, BIG_NUMBER_ZERO); + + // User1 creates the portfolio/NFT + await expect( + context.nestedFactory + .connect(context.user1) + .processOutputOrders(1, [ + { outputToken: context.WETH.address, amounts: [BIG_NUMBER_ZERO], orders, toReserve: true }, + ]), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + it("Should revert if withdraw more than available", async () => { + const mockERC20Factory = await ethers.getContractFactory("MockERC20"); + const vault = mockERC20Factory.attach(context.yearnVaultAddresses.alEthVault); + + const vaultBalance = await vault.balanceOf(context.nestedReserve.address); + + // Orders to withdraw from yearn curve vault + let orders: utils.OrderStruct[] = utils.getYearnCurveWithdrawETHOrder(context, context.yearnVaultAddresses.alEthVault, vaultBalance.mul(2)); + + // User1 creates the portfolio/NFT + await expect( + context.nestedFactory + .connect(context.user1) + .processOutputOrders(1, [ + { outputToken: context.WETH.address, amounts: [vaultBalance], orders, toReserve: true }, + ]), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + it("Should revert if vault is not added in Operator Storage", async () => { + // Orders to withdraw from yearn curve vault + let orders: utils.OrderStruct[] = utils.getYearnCurveWithdrawETHOrder(context, context.yearnVaultAddresses.nonWhitelistedVault, BigNumber.from(100)); + + // User1 creates the portfolio/NFT + await expect( + context.nestedFactory + .connect(context.user1) + .processOutputOrders(1, [ + { outputToken: context.WETH.address, amounts: [BIG_NUMBER_ZERO], orders, toReserve: true }, + ]), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + it("Should revert if minAmountOut is not respected", async () => { + const mockERC20Factory = await ethers.getContractFactory("MockERC20"); + const vault = mockERC20Factory.attach(context.yearnVaultAddresses.alEthVault); + + const vaultBalance = await vault.balanceOf(context.nestedReserve.address); + + // Orders to withdraw from yearn vault and remove liquidity from curve pool + let orders: utils.OrderStruct[] = utils.getYearnCurveWithdrawETHOrder(context, context.yearnVaultAddresses.alEthVault, vaultBalance, UINT256_MAX); + + // User1 creates the portfolio/NFT + await expect( + context.nestedFactory + .connect(context.user1) + .processOutputOrders(1, [ + { outputToken: context.WETH.address, amounts: [vaultBalance], orders, toReserve: true }, + ]), + ).to.be.revertedWith("NF: OPERATOR_CALL_FAILED"); + }); + it("Destroy/Withdraw from Yearn", async () => { + const mockERC20Factory = await ethers.getContractFactory("MockERC20"); + const vault = mockERC20Factory.attach(context.yearnVaultAddresses.alEthVault); + + const vaultBalance = await vault.balanceOf(context.nestedReserve.address); + + // Orders to withdraw from yearn curve vault + let orders: utils.OrderStruct[] = utils.getYearnCurveWithdrawETHOrder(context, context.yearnVaultAddresses.alEthVault, vaultBalance); + + await context.nestedFactory.connect(context.user1).destroy(1, context.WETH.address, orders); + + // All yearn vault token removed from reserve + expect(await vault.balanceOf(context.nestedReserve.address)).to.be.equal(BIG_NUMBER_ZERO); + expect(await vault.balanceOf(context.nestedFactory.address)).to.be.equal(BIG_NUMBER_ZERO); + + /* + * WETH received by the FeeSplitter cannot be predicted. + * It should be greater than 0.01 WETH, but sub 1% to allow a margin of error + */ + expect(await context.WETH.balanceOf(context.feeSplitter.address)).to.be.gt( + getExpectedFees(appendDecimals(1)).sub(getExpectedFees(appendDecimals(1)).div(100)), + ); + }); + }); +}); \ No newline at end of file