diff --git a/contracts/interfaces/external/curve/ICurveMinter.sol b/contracts/interfaces/external/curve/ICurveMinter.sol new file mode 100644 index 000000000..7862541de --- /dev/null +++ b/contracts/interfaces/external/curve/ICurveMinter.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache License, Version 2.0 +pragma solidity 0.6.10; + +interface ICurveMinter { + function coins(uint256) external view returns (address); + + function balances(uint256) external view returns (uint256); + + 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; + + function remove_liquidity(uint256 amount, uint256[2] calldata min_amounts) + external; + + function remove_liquidity(uint256 amount, uint256[3] calldata min_amounts) + external; + + function remove_liquidity(uint256 amount, uint256[4] calldata min_amounts) + external; + + function calc_token_amount(uint256[2] calldata amounts, bool is_deposit) + external view returns(uint256); + + function calc_token_amount(uint256[3] calldata amounts, bool is_deposit) + external view returns(uint256); + + function calc_token_amount(uint256[4] calldata amounts, bool is_deposit) + external view returns(uint256); + + function calc_token_amount(uint256[2] calldata amounts, bool is_deposit, bool previous) + external view returns(uint256); + + function calc_token_amount(uint256[3] calldata amounts, bool is_deposit, bool previous) + external view returns(uint256); + + function calc_token_amount(uint256[4] calldata amounts, bool is_deposit, bool previous) + external view returns(uint256); +} \ No newline at end of file diff --git a/contracts/interfaces/external/curve/ICurveV1.sol b/contracts/interfaces/external/curve/ICurveV1.sol new file mode 100644 index 000000000..f1d185a0d --- /dev/null +++ b/contracts/interfaces/external/curve/ICurveV1.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache License, Version 2.0 +pragma solidity 0.6.10; + +import "./ICurveMinter.sol"; + +interface ICurveV1 is ICurveMinter { + function remove_liquidity_one_coin( + uint256 burn_amount, + int128 i, + uint256 mim_received + ) external; + + function calc_withdraw_one_coin(uint256, int128) + external + view + returns (uint256); + + function exchange( + int128 i, + int128 j, + uint256 dx, + uint256 min_dy + ) external returns (uint256); +} diff --git a/contracts/interfaces/external/curve/ICurveV2.sol b/contracts/interfaces/external/curve/ICurveV2.sol new file mode 100644 index 000000000..3166baf50 --- /dev/null +++ b/contracts/interfaces/external/curve/ICurveV2.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache License, Version 2.0 +pragma solidity 0.6.10; + +import "./ICurveMinter.sol"; + +interface ICurveV2 is ICurveMinter { + function remove_liquidity_one_coin( + uint256 burn_amount, + uint256 i, + uint256 mim_received + ) external; + + function calc_withdraw_one_coin(uint256, uint256) + external + view + returns (uint256); + + function exchange( + uint256 i, + uint256 j, + uint256 dx, + uint256 min_dy + ) external returns (uint256); +} diff --git a/contracts/mocks/external/CurveTwoPoolStableswapMock.sol b/contracts/mocks/external/CurveTwoPoolStableswapMock.sol new file mode 100644 index 000000000..1c8f8c9d4 --- /dev/null +++ b/contracts/mocks/external/CurveTwoPoolStableswapMock.sol @@ -0,0 +1,82 @@ +/* + Copyright 2022 Set Labs Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + SPDX-License-Identifier: Apache License, Version 2.0 +*/ + +pragma solidity 0.6.10; + +import { Address } from "@openzeppelin/contracts/utils/Address.sol"; +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/SafeERC20.sol"; +import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol"; + +// Minimal Curve Stableswap Pool for two coins +contract CurveTwoPoolStableswapMock is ReentrancyGuard, ERC20 { + using SafeERC20 for IERC20; + using SafeMath for uint256; + using SafeMath for int128; + using Address for address; + + address[2] tokens; + + constructor( + string memory _name, + string memory _symbol, + address[2] memory _tokens + ) public ERC20(_name, _symbol) { + tokens = _tokens; + } + + function add_liquidity(uint256[2] memory _amounts, uint256 _min_mint_amount) external nonReentrant { + for (uint i = 0; i < _amounts.length; i++) { + IERC20(tokens[i]).safeTransferFrom(msg.sender, address(this), _amounts[i]); + } + uint256 mint_amount = _amounts[0].add(_amounts[1]); + require(_min_mint_amount <= mint_amount, "invalid min mint amount"); + + _mint(msg.sender, mint_amount); + } + + function remove_liquidity(uint256 amount, uint256[2] calldata min_amounts) external nonReentrant { + _burn(msg.sender, amount); + + for (uint i = 0; i < min_amounts.length; i++) { + require(amount.div(2) >= min_amounts[i], "invalid min amounts"); + IERC20(tokens[i]).safeTransfer(msg.sender, amount.div(2)); + } + } + + function remove_liquidity_one_coin( + uint256 amount, + int128 i, + uint256 mim_received + ) external { + _burn(msg.sender, amount); + + require(amount >= mim_received, "invalid min received"); + IERC20(tokens[uint256(uint128(i))]).safeTransfer(msg.sender, amount); + } + + function coins(uint256 _index) external view returns (address) { + return tokens[_index]; + } + + function balances(uint256 _index) external view returns(uint256) { + return IERC20(tokens[_index]).balanceOf(address(this)); + } +} diff --git a/contracts/protocol/integration/amm/CurveAmmAdapter.sol b/contracts/protocol/integration/amm/CurveAmmAdapter.sol new file mode 100644 index 000000000..14662cd20 --- /dev/null +++ b/contracts/protocol/integration/amm/CurveAmmAdapter.sol @@ -0,0 +1,432 @@ +/* + Copyright 2022 Set Labs Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + SPDX-License-Identifier: Apache License, Version 2.0 +*/ + +pragma solidity 0.6.10; +pragma experimental "ABIEncoderV2"; + +import "@openzeppelin/contracts/math/Math.sol"; +import "@openzeppelin/contracts/math/SafeMath.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/SafeERC20.sol"; + +import "../../../interfaces/IAmmAdapter.sol"; +import "../../../interfaces/external/curve/ICurveMinter.sol"; +import "../../../interfaces/external/curve/ICurveV1.sol"; +import "../../../interfaces/external/curve/ICurveV2.sol"; + +/** + * @title CurveAmmAdapter + * @author deephil + * + * Adapter for Curve that encodes functions for adding and removing liquidity + */ +contract CurveAmmAdapter is IAmmAdapter { + using SafeMath for uint256; + using SafeERC20 for IERC20; + + /* ============ State Variables ============ */ + + // Internal function string for add liquidity + string internal constant ADD_LIQUIDITY = "addLiquidity(address,uint256[],uint256,address)"; + + // Internal function string for remove liquidity + string internal constant REMOVE_LIQUIDITY = "removeLiquidity(address,uint256,uint256[],address)"; + + // Internal function string for remove liquidity one coin + string internal constant REMOVE_LIQUIDITY_ONE_COIN = "removeLiquidityOneCoin(address,uint256,uint256,uint256,address)"; + + // Address of Curve Pool token contract (IERC20 interface) + address public immutable poolToken; + + // Address of Curve Pool minter contract + address public immutable poolMinter; + + // If Curve v1 or Curve v2. + // Curve v1 use `int128` for coin indexes, Curve v2 use `uint256` for coin indexes + bool public immutable isCurveV1; + + // Coin count of Curve Pool + uint256 public immutable coinCount; + + // Coin addresses of Curve Pool + address[] public coins; + + // Coin Index of Curve Pool (starts from 1) + mapping(address => uint256) public coinIndex; + + /* ============ Constructor ============ */ + + /** + * Set state variables + * + * @param _poolToken Address of Curve Pool token + * @param _poolMinter Address of Curve Pool token minter + * @param _isCurveV1 curve v1 or v2 + * @param _coinCount Number of coins in Curve Pool token + */ + constructor( + address _poolToken, + address _poolMinter, + bool _isCurveV1, + uint256 _coinCount + ) public { + require(_poolToken != address(0), "_poolToken can't be zero address"); + require(_poolMinter != address(0), "_poolMinter can't be zero address"); + require(_coinCount >= 2 && _coinCount <= 4, "invalid coin count"); + + poolToken = _poolToken; + poolMinter = _poolMinter; + isCurveV1 = _isCurveV1; + coinCount = _coinCount; + for (uint256 i = 0 ; i < _coinCount ; ++i) { + address coin = ICurveMinter(_poolMinter).coins(i); + coins.push(coin); + coinIndex[coin] = i.add(1); + + IERC20(coin).safeApprove(address(_poolMinter), type(uint256).max); + } + } + + function addLiquidity( + address _pool, + uint256[] memory _amountsIn, + uint256 _minLiquidity, + address _destination + ) external { + require(poolToken == _pool, "invalid pool address"); + require(coinCount == _amountsIn.length, "invalid amounts in"); + require(_minLiquidity != 0, "invalid min liquidity"); + require(_destination != address(0), "invalid destination"); + + bool isValidAmountsIn = false; + for (uint256 i = 0; i < coinCount; ++i) { + if (_amountsIn[i] > 0) { + isValidAmountsIn = true; + } + } + require(isValidAmountsIn, "invalid amounts in"); + + for (uint256 i = 0; i < coinCount; ++i) { + IERC20(coins[i]).safeTransferFrom(msg.sender, address(this), _amountsIn[i]); + } + + if (coinCount == 2) { + ICurveMinter(poolMinter).add_liquidity([_amountsIn[0], _amountsIn[1]], _minLiquidity); + } + else if (coinCount == 3) { + ICurveMinter(poolMinter).add_liquidity([_amountsIn[0], _amountsIn[1], _amountsIn[2]], _minLiquidity); + } + else if (coinCount == 4) { + ICurveMinter(poolMinter).add_liquidity([_amountsIn[0], _amountsIn[1], _amountsIn[2], _amountsIn[3]], _minLiquidity); + } else { + revert("curve supports 2/3/4 coins"); + } + + _transferToken(_pool, _destination); + } + + function removeLiquidity( + address _pool, + uint256 _liquidity, + uint256[] memory _minAmountsOut, + address _destination + ) external { + require(poolToken == _pool, "invalid pool address"); + require(_liquidity != 0, "invalid liquidity"); + require(coinCount == _minAmountsOut.length, "invalid amounts out"); + require(_destination != address(0), "invalid destination"); + + IERC20(_pool).safeTransferFrom(msg.sender, address(this), _liquidity); + + if (coinCount == 2) { + ICurveMinter(poolMinter).remove_liquidity(_liquidity, [_minAmountsOut[0], _minAmountsOut[1]]); + } + else if (coinCount == 3) { + ICurveMinter(poolMinter).remove_liquidity(_liquidity, [_minAmountsOut[0], _minAmountsOut[1], _minAmountsOut[2]]); + } + else if (coinCount == 4) { + ICurveMinter(poolMinter).remove_liquidity(_liquidity, [_minAmountsOut[0], _minAmountsOut[1], _minAmountsOut[2], _minAmountsOut[3]]); + } else { + revert("curve supports 2/3/4 coins"); + } + + for (uint256 i = 0; i < coinCount; ++i) { + _transferToken(coins[i], _destination); + } + } + + function removeLiquidityOneCoin( + address _pool, + uint256 _liquidity, + uint256 _coinIndex, + uint256 _minTokenout, + address _destination + ) external { + require(poolToken == _pool, "invalid pool address"); + require(_liquidity != 0, "invalid liquidity"); + require(_coinIndex < coinCount, "invalid coin index"); + require(_minTokenout != 0, "invalid min token out"); + require(_destination != address(0), "invalid destination"); + + IERC20(_pool).safeTransferFrom(msg.sender, address(this), _liquidity); + + if (isCurveV1) { + ICurveV1(poolMinter).remove_liquidity_one_coin(_liquidity, int128(int256(_coinIndex)), _minTokenout); + } else { + ICurveV2(poolMinter).remove_liquidity_one_coin(_liquidity, _coinIndex, _minTokenout); + } + + _transferToken(coins[_coinIndex], _destination); + } + + /* ============ External Getter Functions ============ */ + + /** + * Return calldata for the add liquidity call + * + * @param _setToken Address of the SetToken + * @param _pool Address of liquidity token + * @param _components Address array required to add liquidity + * @param _maxTokensIn AmountsIn desired to add liquidity + * @param _minLiquidity Min liquidity amount to add + */ + function getProvideLiquidityCalldata( + address _setToken, + address _pool, + address[] calldata _components, + uint256[] calldata _maxTokensIn, + uint256 _minLiquidity + ) + external + view + override + returns (address target, uint256 value, bytes memory data) + { + address[] memory components = _components; + uint256[] memory maxTokensIn = _maxTokensIn; + require(isValidPool(_pool, components), "invalid pool address"); + require(components.length == maxTokensIn.length, "invalid amounts"); + + target = address(this); + value = 0; + data = abi.encodeWithSignature( + ADD_LIQUIDITY, + _pool, + maxTokensIn, + _minLiquidity, + _setToken + ); + } + + /** + * Return calldata for the add liquidity call for a single asset + */ + function getProvideLiquiditySingleAssetCalldata( + address _setToken, + address _pool, + address _component, + uint256 _maxTokenIn, + uint256 _minLiquidity + ) + external + view + override + returns (address target, uint256 value, bytes memory data) + { + require(poolToken == _pool, "invalid pool address"); + require(coinIndex[_component] > 0, "invalid component token"); + require(_maxTokenIn != 0, "invalid component amount"); + + uint256[] memory amountsIn = new uint256[](coinCount); + amountsIn[coinIndex[_component].sub(1)] = _maxTokenIn; + + target = address(this); + value = 0; + data = abi.encodeWithSignature( + ADD_LIQUIDITY, + _pool, + amountsIn, + _minLiquidity, + _setToken + ); + } + + /** + * Return calldata for the remove liquidity call + * + * @param _setToken Address of the SetToken + * @param _pool Address of liquidity token + * @param _components Address array required to remove liquidity + * @param _minTokensOut AmountsOut minimum to remove liquidity + * @param _liquidity Liquidity amount to remove + */ + function getRemoveLiquidityCalldata( + address _setToken, + address _pool, + address[] calldata _components, + uint256[] calldata _minTokensOut, + uint256 _liquidity + ) + external + view + override + returns (address target, uint256 value, bytes memory data) + { + address[] memory components = _components; + uint256[] memory minTokensOut = _minTokensOut; + require(isValidPool(_pool, components), "invalid pool address"); + require(components.length == minTokensOut.length, "invalid amounts"); + + { + // Check liquidity parameter + uint256 setTokenLiquidityBalance = IERC20(_pool).balanceOf(_setToken); + require(_liquidity <= setTokenLiquidityBalance, "_liquidity must be <= to current balance"); + } + + { + // Check minTokensOut parameter + uint256 totalSupply = IERC20(_pool).totalSupply(); + uint256[] memory reserves = _getReserves(); + for (uint256 i = 0; i < coinCount; ++i) { + uint256 reservesOwnedByLiquidity = reserves[i].mul(_liquidity).div(totalSupply); + require(minTokensOut[i] <= reservesOwnedByLiquidity, "amounts must be <= ownedTokens"); + } + } + + target = address(this); + value = 0; + data = abi.encodeWithSignature( + REMOVE_LIQUIDITY, + _pool, + _liquidity, + minTokensOut, + _setToken + ); + } + + /** + * Return calldata for the remove liquidity single asset call + */ + function getRemoveLiquiditySingleAssetCalldata( + address _setToken, + address _pool, + address _component, + uint256 _minTokenOut, + uint256 _liquidity + ) + external + view + override + returns (address target, uint256 value, bytes memory data) + { + require(poolToken == _pool, "invalid pool address"); + require(coinIndex[_component] > 0, "invalid component token"); + + { + // Check liquidity parameter + uint256 setTokenLiquidityBalance = IERC20(_pool).balanceOf(_setToken); + require(_liquidity <= setTokenLiquidityBalance, "_liquidity must be <= to current balance"); + } + + target = address(this); + value = 0; + data = abi.encodeWithSignature( + REMOVE_LIQUIDITY_ONE_COIN, + _pool, + _liquidity, + coinIndex[_component].sub(1), + _minTokenOut, + _setToken + ); + } + + /** + * Returns the address of the spender + */ + function getSpenderAddress(address /*_pool*/) + external + view + override + returns (address spender) + { + spender = address(this); + } + + /** + * Verifies that this is a valid curve pool + * + * @param _pool Address of liquidity token + * @param _components Address array of supplied/requested tokens + */ + function isValidPool(address _pool, address[] memory _components) + public + view + override + returns (bool) { + if (poolToken != _pool) { + return false; + } + + if (_components.length == 1) { + if (coinIndex[_components[0]] == 0) { + return false; + } + } else { + if (coinCount != _components.length) { + return false; + } + for (uint256 i = 0; i < coinCount; ++i) { + if (coins[i] != _components[i]) { + return false; + } + } + } + + return true; + } + + /* ============ Internal Functions =================== */ + + /** + * Returns the Curve Pool token reserves in an expected order + */ + function _getReserves() + internal + view + returns (uint256[] memory reserves) + { + reserves = new uint256[](coinCount); + + for (uint256 i = 0; i < coinCount; ++i) { + reserves[i] = ICurveMinter(poolMinter).balances(i); + } + } + + /** + * Transfer tokens to recipient address + * + * @param token Address of token + * @param recipient Address of recipient + */ + function _transferToken( + address token, + address recipient + ) internal { + IERC20(token).safeTransfer(recipient, IERC20(token).balanceOf(address(this))); + } +} \ No newline at end of file diff --git a/test/integration/curveAmmModule.spec.ts b/test/integration/curveAmmModule.spec.ts new file mode 100644 index 000000000..23ecdf9a4 --- /dev/null +++ b/test/integration/curveAmmModule.spec.ts @@ -0,0 +1,385 @@ +import "module-alias/register"; + +import { ethers, network } from "hardhat"; +import { Address } from "@utils/types"; +import { Account } from "@utils/test/types"; +import { AmmModule, SetToken } from "@utils/contracts"; +import DeployHelper from "@utils/deploys"; +import { + addSnapshotBeforeRestoreAfterEach, + getAccounts, + getSystemFixture, + getWaffleExpect, +} from "@utils/test/index"; +import { ether } from "@utils/index"; +import { SystemFixture } from "@utils/fixtures"; +import { BigNumber, BigNumberish } from "ethers"; +import { CurveAmmAdapter } from "../../typechain/CurveAmmAdapter"; +import { IERC20Metadata } from "../../typechain/IERC20Metadata"; +import { IERC20Metadata__factory } from "../../typechain/factories/IERC20Metadata__factory"; +import { ICurveMinter } from "../../typechain/ICurveMinter"; +import { ICurveMinter__factory } from "../../typechain/factories/ICurveMinter__factory"; +import { parseUnits } from "ethers/lib/utils"; + +const expect = getWaffleExpect(); + +const getTokenFromWhale = async ( + token: IERC20Metadata, + whaleAddress: Address, + recipient: Account, + amount: BigNumber, +) => { + expect(amount).to.gt(0); + + await network.provider.request({ + method: "hardhat_impersonateAccount", + params: [whaleAddress], + }); + await recipient.wallet.sendTransaction({ + from: recipient.address, + to: whaleAddress, + value: ether("0.1"), + }); + const whale = await ethers.getSigner(whaleAddress); + await token.connect(whale).transfer(recipient.address, amount); +}; + +describe("CurveAmmAdapter [ @forked-mainnet ]", () => { + let setToken: SetToken; + let owner: Account, manager: Account; + let deployer: DeployHelper; + let setup: SystemFixture; + let ammModule: AmmModule; + + before(async () => { + [owner, manager] = await getAccounts(); + + deployer = new DeployHelper(owner.wallet); + setup = getSystemFixture(owner.address); + await setup.initialize(); + + ammModule = await deployer.modules.deployAmmModule(setup.controller.address); + await setup.controller.addModule(ammModule.address); + }); + + const runTestScenarioForCurveLP = ({ + scenarioName, + poolTokenAddress, + poolMinterAddress, + isCurveV1, + coinCount, + coinAddresses, + poolTokenWhale, + coinWhales, + }: { + scenarioName: string; + poolTokenAddress: Address; + poolMinterAddress: Address; + isCurveV1: boolean; + coinCount: number; + coinAddresses: Address[]; + poolTokenWhale: Address; + coinWhales: Address[]; + }) => { + describe(scenarioName, () => { + const coins: IERC20Metadata[] = []; + let poolMinter: ICurveMinter; + let poolToken: IERC20Metadata; + let curveAmmAdapter: CurveAmmAdapter; + let curveAmmAdapterName: string; + const coinBalances: BigNumber[] = []; + + before(async () => { + poolMinter = await ICurveMinter__factory.connect(poolMinterAddress, owner.wallet); + // prepare lpToken / each coins from whales + poolToken = await IERC20Metadata__factory.connect(poolTokenAddress, owner.wallet); + await getTokenFromWhale( + poolToken, + poolTokenWhale, + manager, + await poolToken.balanceOf(poolTokenWhale), + ); + for (let i = 0; i < coinCount; i++) { + coins.push(await IERC20Metadata__factory.connect(coinAddresses[i], owner.wallet)); + coinBalances.push(parseUnits("1", await coins[i].decimals())); + await coins[i] + .connect(manager.wallet) + .approve(setup.issuanceModule.address, coinBalances[i]); + await getTokenFromWhale(coins[i], coinWhales[i], manager, coinBalances[i]); + } + + curveAmmAdapter = await deployer.adapters.deployCurveAmmAdapter( + poolTokenAddress, + poolMinterAddress, + isCurveV1, + coinCount, + ); + curveAmmAdapterName = "CURVEAMM" + scenarioName; + + await setup.integrationRegistry.addIntegration( + ammModule.address, + curveAmmAdapterName, + curveAmmAdapter.address, + ); + + // Create Set token + setToken = await setup.createSetToken( + coinAddresses, + coinBalances, + [setup.issuanceModule.address, ammModule.address], + manager.address, + ); + + await ammModule.connect(manager.wallet).initialize(setToken.address); + + // Deploy mock issuance hook and initialize issuance module + const mockPreIssuanceHook = await deployer.mocks.deployManagerIssuanceHookMock(); + await setup.issuanceModule + .connect(manager.wallet) + .initialize(setToken.address, mockPreIssuanceHook.address); + + const issueQuantity = ether(1); + await setup.issuanceModule + .connect(manager.wallet) + .issue(setToken.address, issueQuantity, owner.address); + }); + + addSnapshotBeforeRestoreAfterEach(); + + describe("#addLiquidity", () => { + let subjectSetToken: Address; + let subjectAmmAdapterName: string; + let subjectPoolToken: Address; + let subjectMinLiquidity: BigNumber; + let subjectCoinAddresses: Address[]; + let subjectCoinBalances: BigNumber[]; + + beforeEach(() => { + subjectSetToken = setToken.address; + subjectAmmAdapterName = curveAmmAdapterName; + subjectPoolToken = poolTokenAddress; + subjectMinLiquidity = BigNumber.from(1); + subjectCoinAddresses = coinAddresses; + subjectCoinBalances = coinBalances; + }); + + const subject = async () => { + await ammModule + .connect(manager.wallet) + .addLiquidity( + subjectSetToken, + subjectAmmAdapterName, + subjectPoolToken, + subjectMinLiquidity, + subjectCoinAddresses, + subjectCoinBalances, + ); + }; + + const expectCloseTo = (a: BigNumber, b: BigNumber, delta: BigNumberish) => { + expect(a).to.gt(b.sub(delta)); + expect(a).to.lt(b.add(delta)); + }; + + it("should transfer correct components and get LP tokens", async () => { + const lpBalanceBefore = await poolToken.balanceOf(setToken.address); + for (let i = 0; i < coinCount; i++) { + expect(await coins[i].balanceOf(setToken.address)).to.eq(subjectCoinBalances[i]); + } + + let expectedNewLpTokens = BigNumber.from(0); + switch (coinCount) { + case 2: + expectedNewLpTokens = await poolMinter["calc_token_amount(uint256[2],bool)"]( + [coinBalances[0], coinBalances[1]], + true, + ); + break; + case 3: + expectedNewLpTokens = await poolMinter["calc_token_amount(uint256[3],bool)"]( + [coinBalances[0], coinBalances[1], coinBalances[2]], + true, + ); + break; + case 4: + expectedNewLpTokens = await poolMinter["calc_token_amount(uint256[4],bool)"]( + [coinBalances[0], coinBalances[1], coinBalances[2], coinBalances[3]], + true, + ); + break; + } + + await subject(); + + // `calc_token_amount` of `USDT/WBTC/WETH` pool return correct amount out for add_liquidity, but it doesn't return for `MIM/3CRV` pools. + // there is some external logic for fees, here we test actual output and expected output with 0.1% slippage + const newLpTokens = (await poolToken.balanceOf(setToken.address)).sub(lpBalanceBefore); + expectCloseTo( + newLpTokens, + expectedNewLpTokens, + expectedNewLpTokens.div(1000), // 0.1% + ); + for (let i = 0; i < coinCount; i++) { + expect(await coins[i].balanceOf(setToken.address)).to.eq(0); + } + }); + }); + + describe("#removeLiquidity", () => { + let subjectSetToken: Address; + let subjectAmmAdapterName: string; + let subjectPoolToken: Address; + let subjectPoolTokenPositionUnits: BigNumber; + let subjectComponents: Address[]; + let subjectMinComponentUnitsReceived: BigNumber[]; + + beforeEach(async () => { + await ammModule + .connect(manager.wallet) + .addLiquidity( + setToken.address, + curveAmmAdapterName, + poolTokenAddress, + 1, + coinAddresses, + coinBalances, + ); + + subjectSetToken = setToken.address; + subjectAmmAdapterName = curveAmmAdapterName; + subjectPoolToken = poolTokenAddress; + subjectPoolTokenPositionUnits = await poolToken.balanceOf(setToken.address); + subjectComponents = coinAddresses; + subjectMinComponentUnitsReceived = Array(coinCount).fill(1); + }); + + const subject = async () => { + await ammModule + .connect(manager.wallet) + .removeLiquidity( + subjectSetToken, + subjectAmmAdapterName, + subjectPoolToken, + subjectPoolTokenPositionUnits, + subjectComponents, + subjectMinComponentUnitsReceived, + ); + }; + + it("should transfer LP tokens and get component tokens", async () => { + const lpBalanceBefore = await poolToken.balanceOf(setToken.address); + for (let i = 0; i < coinCount; i++) { + expect(await coins[i].balanceOf(setToken.address)).to.eq(0); + } + + await subject(); + + expect(await poolToken.balanceOf(setToken.address)).to.eq( + lpBalanceBefore.sub(subjectPoolTokenPositionUnits), + ); + for (let i = 0; i < coinCount; i++) { + expect(await coins[i].balanceOf(setToken.address)).to.gt(0); + } + }); + }); + + describe("removeLiquiditySingleAsset", () => { + let subjectSetToken: Address; + let subjectAmmAdapterName: string; + let subjectPoolToken: Address; + let subjectPoolTokenPositionUnits: BigNumber; + let subjectComponent: Address; + let subjectMinComponentUnitReceived: BigNumber; + + beforeEach(async () => { + await ammModule + .connect(manager.wallet) + .addLiquidity( + setToken.address, + curveAmmAdapterName, + poolTokenAddress, + 1, + coinAddresses, + coinBalances, + ); + + subjectSetToken = setToken.address; + subjectAmmAdapterName = curveAmmAdapterName; + subjectPoolToken = poolTokenAddress; + subjectPoolTokenPositionUnits = await poolToken.balanceOf(setToken.address); + subjectComponent = coinAddresses[1]; + subjectMinComponentUnitReceived = BigNumber.from(1); + }); + + const subject = async () => { + await ammModule + .connect(manager.wallet) + .removeLiquiditySingleAsset( + subjectSetToken, + subjectAmmAdapterName, + subjectPoolToken, + subjectPoolTokenPositionUnits, + subjectComponent, + subjectMinComponentUnitReceived, + ); + }; + + it("should transfer LP tokens and get only one component token", async () => { + for (let i = 0; i < coinCount; i++) { + expect(await coins[i].balanceOf(setToken.address)).to.eq(0); + } + + await subject(); + + expect(await poolToken.balanceOf(setToken.address)).to.eq(0); + for (let i = 0; i < coinCount; i++) { + if (coinAddresses[i] === subjectComponent) { + expect(await coins[i].balanceOf(setToken.address)).to.gt(0); + } else { + expect(await coins[i].balanceOf(setToken.address)).to.eq(0); + } + } + }); + }); + }); + }; + + const testScenarios = [ + { + scenarioName: "Curve LP with 2 coins (v1) - MIM/3CRV", + poolTokenAddress: "0x5a6A4D54456819380173272A5E8E9B9904BdF41B", + poolMinterAddress: "0x5a6A4D54456819380173272A5E8E9B9904BdF41B", + isCurveV1: true, + coinCount: 2, + coinAddresses: [ + "0x99D8a9C45b2ecA8864373A26D1459e3Dff1e17F3", // MIM + "0x6c3F90f043a72FA612cbac8115EE7e52BDe6E490", // 3CRV + ], + poolTokenWhale: "0x11B49699aa0462a0488d93aEFdE435D4D6608469", // Curve MIM/3CRV whale + coinWhales: [ + "0x4240781A9ebDB2EB14a183466E8820978b7DA4e2", // MIM whale + "0x5438649eE5B0150B2cd218004aA324075e2f292C", // 3CRV whale + ], + }, + { + scenarioName: "Curve LP with 3 coins (v2) - USDT/WBTC/WETH", + poolTokenAddress: "0xc4AD29ba4B3c580e6D59105FFf484999997675Ff", + poolMinterAddress: "0xD51a44d3FaE010294C616388b506AcdA1bfAAE46", + isCurveV1: false, + coinCount: 3, + coinAddresses: [ + "0xdAC17F958D2ee523a2206206994597C13D831ec7", // USDT + "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", // WBTC + "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", // WETH + ], + poolTokenWhale: "0xfE4d9D4F102b40193EeD8aA6C52BD87a328177fc", // Curve USDT/WBTC/WETH whale + coinWhales: [ + "0x5a52E96BAcdaBb82fd05763E25335261B270Efcb", // USDT whale + "0x2FAF487A4414Fe77e2327F0bf4AE2a264a776AD2", // WBTC whale + "0x56178a0d5F301bAf6CF3e1Cd53d9863437345Bf9", // WETH whale + ], + }, + ]; + + testScenarios.forEach(scenario => runTestScenarioForCurveLP(scenario)); +}); diff --git a/test/protocol/integration/amm/CurveAmmAdapter.spec.ts b/test/protocol/integration/amm/CurveAmmAdapter.spec.ts new file mode 100644 index 000000000..a3614677b --- /dev/null +++ b/test/protocol/integration/amm/CurveAmmAdapter.spec.ts @@ -0,0 +1,646 @@ +import "module-alias/register"; + +import { Address } from "@utils/types"; +import { Account } from "@utils/test/types"; +import { AmmModule, StandardTokenMock } from "@utils/contracts"; +import DeployHelper from "@utils/deploys"; +import { + addSnapshotBeforeRestoreAfterEach, + getAccounts, + getSystemFixture, + getWaffleExpect, +} from "@utils/test/index"; +import { ether } from "@utils/index"; +import { SystemFixture } from "@utils/fixtures"; +import { ADDRESS_ZERO, ZERO } from "@utils/constants"; +import { BigNumber } from "ethers"; +import { CurveAmmAdapter } from "../../../../typechain/CurveAmmAdapter"; +import { ICurveMinter } from "../../../../typechain/ICurveMinter"; +import { IERC20 } from "../../../../typechain/IERC20"; +import { CurveTwoPoolStableswapMock__factory } from "../../../../typechain/factories/CurveTwoPoolStableswapMock__factory"; +import { IERC20__factory } from "../../../../typechain/factories/IERC20__factory"; +import { ICurveMinter__factory } from "../../../../typechain/factories/ICurveMinter__factory"; +import { ethers } from "hardhat"; + +const expect = getWaffleExpect(); + +describe("CurveAmmAdapter", () => { + let owner: Account; + let liquidityProvider: Account; + let deployer: DeployHelper; + let setup: SystemFixture; + let ammModule: AmmModule; + + const coins: StandardTokenMock[] = []; + const coinAddresses: Address[] = []; + let poolToken: IERC20; + let poolMinter: ICurveMinter; + let isCurveV1: boolean; + let coinCount: number; + let poolTokenAddress: Address; + let poolMinterAddress: Address; + + let curveAmmAdapter: CurveAmmAdapter; + let curveAmmAdapterName: string; + + const getReserves = async (): Promise => { + const reserves: BigNumber[] = []; + for (let i = 0; i < coinCount; i++) { + reserves.push(await poolMinter.balances(i)); + } + return reserves; + }; + + before(async () => { + [owner, liquidityProvider] = await getAccounts(); + + deployer = new DeployHelper(owner.wallet); + setup = getSystemFixture(owner.address); + await setup.initialize(); + + ammModule = await deployer.modules.deployAmmModule(setup.controller.address); + await setup.controller.addModule(ammModule.address); + + isCurveV1 = true; + coinCount = 2; + // deploy mocked coins for Curve Pool + for (let i = 0; i < coinCount; i++) { + coins.push(await deployer.mocks.deployTokenMock(owner.address, 0, 18)); + coinAddresses.push(coins[i].address); + await coins[i].mint(liquidityProvider.address, ethers.utils.parseUnits("100")); + await coins[i].mint(owner.address, ethers.utils.parseUnits("10")); + } + + // deployed mocked Curve Pool + const curvePool = await new CurveTwoPoolStableswapMock__factory( + owner.wallet, + ).deploy("Curve.fi Pool ERC20", "CPE", [coinAddresses[0], coinAddresses[1]]); + + poolToken = IERC20__factory.connect(curvePool.address, owner.wallet); + poolMinter = ICurveMinter__factory.connect(curvePool.address, owner.wallet); + + poolTokenAddress = poolToken.address; + poolMinterAddress = poolMinter.address; + + // provide liquidity + for (let i = 0; i < coinCount; i++) { + await coins[i] + .connect(liquidityProvider.wallet) + .approve(poolMinter.address, ethers.utils.parseUnits("100")); + } + await poolMinter + .connect(liquidityProvider.wallet) + ["add_liquidity(uint256[2],uint256)"]( + [ethers.utils.parseUnits("100"), ethers.utils.parseUnits("100")], + 0, + { + gasLimit: "10000000", + }, + ); + await poolToken.connect(liquidityProvider.wallet).transfer(owner.address, ether(10)); + + // deploy CurveAmmAdapter + curveAmmAdapter = await deployer.adapters.deployCurveAmmAdapter( + poolTokenAddress, + poolMinterAddress, + isCurveV1, + coinCount, + ); + curveAmmAdapterName = "CURVEAMM"; + + // add integraion + await setup.integrationRegistry.addIntegration( + ammModule.address, + curveAmmAdapterName, + curveAmmAdapter.address, + ); + }); + + addSnapshotBeforeRestoreAfterEach(); + + describe("#constructor", () => { + it("should have correct pool poolToken address", async () => { + expect(await curveAmmAdapter.poolToken()).to.eq(poolTokenAddress); + }); + + it("should have correct pool poolMinter address", async () => { + expect(await curveAmmAdapter.poolMinter()).to.eq(poolMinterAddress); + }); + + it("should have correct flag for Curve v1/v2", async () => { + expect(await curveAmmAdapter.isCurveV1()).to.eq(isCurveV1); + }); + + it("should have correct coins count", async () => { + expect(await curveAmmAdapter.coinCount()).to.eq(coinCount); + }); + + it("should have correct coins", async () => { + for (let i = 0; i < coinCount; i++) { + expect(await curveAmmAdapter.coins(i)).to.eq(coinAddresses[i]); + } + }); + + it("should have correct coin indexes", async () => { + for (let i = 0; i < coinCount; i++) { + expect(await curveAmmAdapter.coinIndex(coinAddresses[i])).to.eq(i + 1); + } + }); + }); + + describe("#getSpenderAddress", () => { + it("should return the correct spender address", async () => { + expect(await curveAmmAdapter.getSpenderAddress(poolTokenAddress)).to.eq( + curveAmmAdapter.address, + ); + }); + }); + + describe("#isValidPool", () => { + it("should return false if invalid pool address", async () => { + expect(await curveAmmAdapter.isValidPool(ADDRESS_ZERO, [])).to.eq(false); + }); + + it("should return false if components count doesnt match", async () => { + expect(await curveAmmAdapter.isValidPool(poolTokenAddress, [])).to.eq(false); + expect(await curveAmmAdapter.isValidPool(poolTokenAddress, [ADDRESS_ZERO])).to.eq(false); + }); + + it("should return false if components address doesn't match", async () => { + let components = [...coinAddresses]; + components[0] = ADDRESS_ZERO; + expect(await curveAmmAdapter.isValidPool(poolTokenAddress, components)).to.eq(false); + + components = [...coinAddresses]; + components[1] = ADDRESS_ZERO; + expect(await curveAmmAdapter.isValidPool(poolTokenAddress, components)).to.eq(false); + }); + + it("should return true if correct pool & components address", async () => { + // addLiquidity / removeLiquidity + expect(await curveAmmAdapter.isValidPool(poolTokenAddress, coinAddresses)).to.eq(true); + // removeLiquiditySingleAsset + for (let i = 0; i < coinCount; i++) { + expect(await curveAmmAdapter.isValidPool(poolTokenAddress, [coinAddresses[i]])).to.eq(true); + } + }); + }); + + describe("#getProvideLiquidityCalldata", () => { + let subjectAmmPool: Address; + let subjectComponents: Address[]; + let subjectMaxTokensIn: BigNumber[]; + let subjectMinLiquidity: BigNumber; + let reserves: BigNumber[]; + let totalSupply: BigNumber; + + beforeEach(async () => { + reserves = await getReserves(); + totalSupply = await poolToken.totalSupply(); + + subjectAmmPool = poolTokenAddress; + subjectComponents = coinAddresses; + subjectMaxTokensIn = reserves.map(balance => balance.div(100)); + subjectMinLiquidity = totalSupply.div(100); + }); + + const subject = async () => { + return await curveAmmAdapter.getProvideLiquidityCalldata( + owner.address, + subjectAmmPool, + subjectComponents, + subjectMaxTokensIn, + subjectMinLiquidity, + ); + }; + + it("should return the correct provide liquidity calldata", async () => { + const calldata = await subject(); + + const expectedCallData = curveAmmAdapter.interface.encodeFunctionData("addLiquidity", [ + poolTokenAddress, + subjectMaxTokensIn, + subjectMinLiquidity, + owner.address, + ]); + + expect(JSON.stringify(calldata)).to.eq( + JSON.stringify([curveAmmAdapter.address, ZERO, expectedCallData]), + ); + }); + + it("should revert if invalid pool address", async () => { + subjectAmmPool = ADDRESS_ZERO; + await expect(subject()).to.revertedWith("invalid pool address"); + }); + + it("should revert if amounts length doesn't match", async () => { + subjectMaxTokensIn = []; + await expect(subject()).to.revertedWith("invalid amounts"); + }); + }); + + describe("#getProvideLiquiditySingleAssetCalldata", () => { + let subjectAmmPool: Address; + let subjectComponent: Address; + let subjectMaxTokenIn: BigNumber; + let subjectMinLiquidity: BigNumber; + let reserves: BigNumber[]; + let totalSupply: BigNumber; + + beforeEach(async () => { + reserves = await getReserves(); + totalSupply = await poolToken.totalSupply(); + + subjectAmmPool = poolTokenAddress; + subjectComponent = coinAddresses[1]; + subjectMaxTokenIn = reserves[1].div(100); + subjectMinLiquidity = totalSupply.div(100); + }); + + const subject = async () => { + return await curveAmmAdapter.getProvideLiquiditySingleAssetCalldata( + owner.address, + subjectAmmPool, + subjectComponent, + subjectMaxTokenIn, + subjectMinLiquidity, + ); + }; + + it("should return the correct provide liquidity calldata", async () => { + const calldata = await subject(); + + const amountsIn = Array(coinCount).fill(0); + amountsIn[1] = subjectMaxTokenIn; + const expectedCallData = curveAmmAdapter.interface.encodeFunctionData("addLiquidity", [ + poolTokenAddress, + amountsIn, + subjectMinLiquidity, + owner.address, + ]); + + expect(JSON.stringify(calldata)).to.eq( + JSON.stringify([curveAmmAdapter.address, ZERO, expectedCallData]), + ); + }); + + it("should revert if invalid pool address", async () => { + subjectAmmPool = ADDRESS_ZERO; + await expect(subject()).to.revertedWith("invalid pool address"); + }); + + it("should revert if invalid component token", async () => { + subjectComponent = ADDRESS_ZERO; + await expect(subject()).to.revertedWith("invalid component token"); + }); + + it("should revert if invalid component amount", async () => { + subjectMaxTokenIn = BigNumber.from(0); + await expect(subject()).to.revertedWith("invalid component amount"); + }); + }); + + describe("#getRemoveLiquidityCalldata", () => { + let subjectAmmPool: Address; + let subjectComponents: Address[]; + let subjectMinTokensOut: BigNumber[]; + let subjectLiquidity: BigNumber; + let reserves: BigNumber[]; + let totalSupply: BigNumber; + + beforeEach(async () => { + reserves = await getReserves(); + totalSupply = await poolToken.totalSupply(); + + subjectAmmPool = poolTokenAddress; + subjectComponents = coinAddresses; + subjectLiquidity = await poolToken.balanceOf(owner.address); + subjectMinTokensOut = reserves.map(balance => + balance.mul(subjectLiquidity).div(totalSupply), + ); + }); + + const subject = async () => { + return await curveAmmAdapter.getRemoveLiquidityCalldata( + owner.address, + subjectAmmPool, + subjectComponents, + subjectMinTokensOut, + subjectLiquidity, + ); + }; + + it("should return the correct provide liquidity calldata", async () => { + const calldata = await subject(); + + const expectedCallData = curveAmmAdapter.interface.encodeFunctionData("removeLiquidity", [ + poolTokenAddress, + subjectLiquidity, + subjectMinTokensOut, + owner.address, + ]); + + expect(JSON.stringify(calldata)).to.eq( + JSON.stringify([curveAmmAdapter.address, ZERO, expectedCallData]), + ); + }); + + it("should revert if invalid pool address", async () => { + subjectAmmPool = ADDRESS_ZERO; + await expect(subject()).to.revertedWith("invalid pool address"); + }); + + it("should revert if poolToken amounts length doesn't match", async () => { + subjectMinTokensOut = []; + await expect(subject()).to.revertedWith("invalid amounts"); + }); + + it("should revert if liquidity is more than the balance", async () => { + subjectLiquidity = subjectLiquidity.add(1); + await expect(subject()).to.revertedWith("_liquidity must be <= to current balance"); + }); + + it("should revert if poolToken amounts is more than the liquidity", async () => { + subjectMinTokensOut[1] = subjectMinTokensOut[1].mul(2); + await expect(subject()).to.revertedWith("amounts must be <= ownedTokens"); + }); + }); + + describe("#getRemoveLiquiditySingleAssetCalldata", () => { + let subjectAmmPool: Address; + let subjectComponent: Address; + let subjectMinTokenOut: BigNumber; + let subjectLiquidity: BigNumber; + let reserves: BigNumber[]; + let totalSupply: BigNumber; + + beforeEach(async () => { + reserves = await getReserves(); + totalSupply = await poolToken.totalSupply(); + + subjectAmmPool = poolTokenAddress; + subjectComponent = coinAddresses[1]; + subjectLiquidity = await poolToken.balanceOf(owner.address); + subjectMinTokenOut = reserves[1].mul(subjectLiquidity).div(totalSupply); + }); + + const subject = async () => { + return await curveAmmAdapter.getRemoveLiquiditySingleAssetCalldata( + owner.address, + subjectAmmPool, + subjectComponent, + subjectMinTokenOut, + subjectLiquidity, + ); + }; + + it("should return the correct provide liquidity calldata", async () => { + const calldata = await subject(); + + const expectedCallData = curveAmmAdapter.interface.encodeFunctionData( + "removeLiquidityOneCoin", + [poolTokenAddress, subjectLiquidity, 1, subjectMinTokenOut, owner.address], + ); + + expect(JSON.stringify(calldata)).to.eq( + JSON.stringify([curveAmmAdapter.address, ZERO, expectedCallData]), + ); + }); + + it("should revert if invalid pool address", async () => { + subjectAmmPool = ADDRESS_ZERO; + await expect(subject()).to.revertedWith("invalid pool address"); + }); + + it("should revert if invalid component token", async () => { + subjectComponent = ADDRESS_ZERO; + await expect(subject()).to.revertedWith("invalid component token"); + }); + + it("should revert if liquidity is more than the balance", async () => { + subjectLiquidity = subjectLiquidity.add(1); + await expect(subject()).to.revertedWith("_liquidity must be <= to current balance"); + }); + }); + + describe("#addLiquidity", () => { + let subjectAmmPool: Address; + let subjectMaxTokensIn: BigNumber[]; + let subjectMinLiquidity: BigNumber; + let subjectDestination: Address; + let reserves: BigNumber[]; + + beforeEach(async () => { + reserves = await getReserves(); + + subjectAmmPool = poolTokenAddress; + subjectMaxTokensIn = reserves.map(balance => balance.div(100)); + subjectMinLiquidity = BigNumber.from(1); + subjectDestination = owner.address; + + for (let i = 0; i < coinCount; i++) { + await coins[i].approve(curveAmmAdapter.address, subjectMaxTokensIn[i]); + } + }); + + const subject = async () => { + return await curveAmmAdapter.addLiquidity( + subjectAmmPool, + subjectMaxTokensIn, + subjectMinLiquidity, + subjectDestination, + ); + }; + + it("should revert if invalid pool address", async () => { + subjectAmmPool = ADDRESS_ZERO; + await expect(subject()).to.revertedWith("invalid pool address"); + }); + + it("should revert if amounts length doesn't match", async () => { + subjectMaxTokensIn = []; + await expect(subject()).to.revertedWith("invalid amounts"); + }); + + it("should revert if amounts are all zero", async () => { + subjectMaxTokensIn = subjectMaxTokensIn.map(() => BigNumber.from("0")); + await expect(subject()).to.revertedWith("invalid amounts"); + }); + + it("should revert if zero min liquidity", async () => { + subjectMinLiquidity = BigNumber.from("0"); + await expect(subject()).to.revertedWith("invalid min liquidity"); + }); + + it("should revert if destinatination address is zero", async () => { + subjectDestination = ADDRESS_ZERO; + await expect(subject()).to.revertedWith("invalid destination"); + }); + + it("should add liquidity and get LP token", async () => { + const liquidityBefore = await poolToken.balanceOf(owner.address); + const coinBalancesBefore = []; + for (let i = 0; i < coinCount; i++) { + coinBalancesBefore[i] = await coins[i].balanceOf(owner.address); + } + + await subject(); + + expect(await poolToken.balanceOf(owner.address)).to.gt(liquidityBefore); + for (let i = 0; i < coinCount; i++) { + expect(await coins[i].balanceOf(owner.address)).to.lt(coinBalancesBefore[i]); + } + + // no tokens remain after transfer + expect(await poolToken.balanceOf(curveAmmAdapter.address)).to.equal(0); + for (let i = 0; i < coinCount; i++) { + expect(await coins[i].balanceOf(curveAmmAdapter.address)).to.equal(0); + } + }); + }); + + describe("#removeLiquidity", () => { + let subjectAmmPool: Address; + let subjectLiquidity: BigNumber; + let subjectMinAmountsOut: BigNumber[]; + let subjectDestination: Address; + + beforeEach(async () => { + subjectAmmPool = poolTokenAddress; + subjectMinAmountsOut = Array(coinCount).fill(0); + subjectLiquidity = await poolToken.balanceOf(owner.address); + subjectDestination = owner.address; + + await poolToken.approve(curveAmmAdapter.address, subjectLiquidity); + }); + + const subject = async () => { + return await curveAmmAdapter.removeLiquidity( + subjectAmmPool, + subjectLiquidity, + subjectMinAmountsOut, + subjectDestination, + ); + }; + + it("should revert if invalid pool address", async () => { + subjectAmmPool = ADDRESS_ZERO; + await expect(subject()).to.revertedWith("invalid pool address"); + }); + + it("should revert if zero liquidity", async () => { + subjectLiquidity = BigNumber.from(0); + await expect(subject()).to.revertedWith("invalid liquidity"); + }); + + it("should revert if amounts length doesn't match", async () => { + subjectMinAmountsOut = []; + await expect(subject()).to.revertedWith("invalid amounts"); + }); + + it("should revert if destinatination address is zero", async () => { + subjectDestination = ADDRESS_ZERO; + await expect(subject()).to.revertedWith("invalid destination"); + }); + + it("should remove liquidity and get expect coins", async () => { + const liquidityBefore = await poolToken.balanceOf(owner.address); + const coinBalancesBefore = []; + for (let i = 0; i < coinCount; i++) { + coinBalancesBefore[i] = await coins[i].balanceOf(owner.address); + } + + await subject(); + + expect(await poolToken.balanceOf(owner.address)).to.lt(liquidityBefore); + for (let i = 0; i < coinCount; i++) { + expect(await coins[i].balanceOf(owner.address)).to.gt(coinBalancesBefore[i]); + } + + // no tokens remain after transfer + expect(await poolToken.balanceOf(curveAmmAdapter.address)).to.equal(0); + for (let i = 0; i < coinCount; i++) { + expect(await coins[i].balanceOf(curveAmmAdapter.address)).to.equal(0); + } + }); + }); + + describe("#removeLiquidityOneCoin", () => { + let subjectAmmPool: Address; + let subjectLiquidity: BigNumber; + let subjectCoinIndex: number; + let subjectMinTokenOut: BigNumber; + let subjectDestination: Address; + + beforeEach(async () => { + subjectAmmPool = poolTokenAddress; + subjectLiquidity = await poolToken.balanceOf(owner.address); + subjectCoinIndex = 1; + subjectMinTokenOut = BigNumber.from(1); + subjectDestination = owner.address; + + await poolToken.approve(curveAmmAdapter.address, subjectLiquidity); + }); + + const subject = async () => { + return await curveAmmAdapter.removeLiquidityOneCoin( + subjectAmmPool, + subjectLiquidity, + subjectCoinIndex, + subjectMinTokenOut, + subjectDestination, + ); + }; + + it("should revert if invalid pool address", async () => { + subjectAmmPool = ADDRESS_ZERO; + await expect(subject()).to.revertedWith("invalid pool address"); + }); + + it("should revert if zero liquidity", async () => { + subjectLiquidity = BigNumber.from(0); + await expect(subject()).to.revertedWith("invalid liquidity"); + }); + + it("should revert if invalid coinIndex", async () => { + subjectCoinIndex = 4; + await expect(subject()).to.revertedWith("invalid coin index"); + }); + + it("should revert if zero min token out", async () => { + subjectMinTokenOut = BigNumber.from(0); + await expect(subject()).to.revertedWith("invalid min token out"); + }); + + it("should revert if destinatination address is zero", async () => { + subjectDestination = ADDRESS_ZERO; + await expect(subject()).to.revertedWith("invalid destination"); + }); + + it("should remove liquidity and get exact one token out", async () => { + const liquidityBefore = await poolToken.balanceOf(owner.address); + const coinBalancesBefore = []; + for (let i = 0; i < coinCount; i++) { + coinBalancesBefore[i] = await coins[i].balanceOf(owner.address); + } + + await subject(); + + expect(await poolToken.balanceOf(owner.address)).to.lt(liquidityBefore); + for (let i = 0; i < coinCount; i++) { + if (i === subjectCoinIndex) { + expect(await coins[i].balanceOf(owner.address)).to.gt(coinBalancesBefore[i]); + } else { + expect(await coins[i].balanceOf(owner.address)).to.eq(coinBalancesBefore[i]); + } + } + + // no tokens remain after transfer + expect(await poolToken.balanceOf(curveAmmAdapter.address)).to.equal(0); + for (let i = 0; i < coinCount; i++) { + expect(await coins[i].balanceOf(curveAmmAdapter.address)).to.equal(0); + } + }); + }); +}); diff --git a/utils/contracts/index.ts b/utils/contracts/index.ts index 593889b16..50dd346ba 100644 --- a/utils/contracts/index.ts +++ b/utils/contracts/index.ts @@ -108,6 +108,7 @@ export { Uint256ArrayUtilsMock } from "../../typechain/Uint256ArrayUtilsMock"; export { Uni } from "../../typechain/Uni"; export { UniswapPairPriceAdapter } from "../../typechain/UniswapPairPriceAdapter"; export { UniswapV2AmmAdapter } from "../../typechain/UniswapV2AmmAdapter"; +export { CurveAmmAdapter } from "../../typechain/CurveAmmAdapter"; export { UniswapV2ExchangeAdapter } from "../../typechain/UniswapV2ExchangeAdapter"; export { UniswapV2ExchangeAdapterV2 } from "../../typechain/UniswapV2ExchangeAdapterV2"; export { UniswapV2IndexExchangeAdapter } from "../../typechain/UniswapV2IndexExchangeAdapter"; diff --git a/utils/deploys/deployAdapters.ts b/utils/deploys/deployAdapters.ts index 72af8e9d1..22f3f6a40 100644 --- a/utils/deploys/deployAdapters.ts +++ b/utils/deploys/deployAdapters.ts @@ -15,6 +15,7 @@ import { YearnWrapV2Adapter, UniswapPairPriceAdapter, UniswapV2AmmAdapter, + CurveAmmAdapter, UniswapV2ExchangeAdapter, UniswapV2ExchangeAdapterV2, UniswapV2IndexExchangeAdapter, @@ -47,6 +48,7 @@ import { YearnWrapV2Adapter__factory } from "../../typechain/factories/YearnWrap import { UniswapPairPriceAdapter__factory } from "../../typechain/factories/UniswapPairPriceAdapter__factory"; import { UniswapV2ExchangeAdapter__factory } from "../../typechain/factories/UniswapV2ExchangeAdapter__factory"; import { UniswapV2AmmAdapter__factory } from "../../typechain/factories/UniswapV2AmmAdapter__factory"; +import { CurveAmmAdapter__factory } from "../../typechain/factories/CurveAmmAdapter__factory"; import { UniswapV2TransferFeeExchangeAdapter__factory } from "../../typechain/factories/UniswapV2TransferFeeExchangeAdapter__factory"; import { UniswapV2ExchangeAdapterV2__factory } from "../../typechain/factories/UniswapV2ExchangeAdapterV2__factory"; import { UniswapV2IndexExchangeAdapter__factory } from "../../typechain/factories/UniswapV2IndexExchangeAdapter__factory"; @@ -88,6 +90,20 @@ export default class DeployAdapters { return await new UniswapV2AmmAdapter__factory(this._deployerSigner).deploy(uniswapV2Router); } + public async deployCurveAmmAdapter( + poolToken: Address, + poolMinter: Address, + isCurveV1: boolean, + coinCount: number, + ): Promise { + return await new CurveAmmAdapter__factory(this._deployerSigner).deploy( + poolToken, + poolMinter, + isCurveV1, + coinCount, + ); + } + public async deployUniswapV2ExchangeAdapter( uniswapV2Router: Address, ): Promise {