From bf393f1fa5839cd87989cda69db066fe72bfe95a Mon Sep 17 00:00:00 2001 From: deephil0226 Date: Fri, 8 Jul 2022 08:18:21 -0400 Subject: [PATCH 1/9] introduce CurveAmmAdapter --- .../external/curve/ICurveMinter.sol | 26 ++ .../interfaces/external/curve/ICurveV1.sol | 24 ++ .../interfaces/external/curve/ICurveV2.sol | 24 ++ .../integration/amm/CurveAmmAdapter.sol | 381 ++++++++++++++++++ 4 files changed, 455 insertions(+) create mode 100644 contracts/interfaces/external/curve/ICurveMinter.sol create mode 100644 contracts/interfaces/external/curve/ICurveV1.sol create mode 100644 contracts/interfaces/external/curve/ICurveV2.sol create mode 100644 contracts/protocol/integration/amm/CurveAmmAdapter.sol diff --git a/contracts/interfaces/external/curve/ICurveMinter.sol b/contracts/interfaces/external/curve/ICurveMinter.sol new file mode 100644 index 000000000..0d1c8250b --- /dev/null +++ b/contracts/interfaces/external/curve/ICurveMinter.sol @@ -0,0 +1,26 @@ +// 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; +} \ 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/protocol/integration/amm/CurveAmmAdapter.sol b/contracts/protocol/integration/amm/CurveAmmAdapter.sol new file mode 100644 index 000000000..0983dabb0 --- /dev/null +++ b/contracts/protocol/integration/amm/CurveAmmAdapter.sol @@ -0,0 +1,381 @@ +/* + Copyright 2021 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 "../../../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 adding and removing liquidty + */ +contract CurveAmmAdapter is IAmmAdapter { + using SafeMath for uint256; + + /* ============ State Variables ============ */ + + address public immutable poolToken; + address public immutable poolMinter; + bool public immutable isCurveV1; + + uint256 public immutable coinCount; + address[] public coins; + mapping(address => uint256) public coinIndex; // starts from 1 + + /* ============ Constructor ============ */ + + /** + * Set state variables + * + * @param _poolToken Address of Curve LP token + * @param _poolMinter Address of Curve LP token minter + * @param _isCurveV1 curve v1 or v2 + * @param _coinCount Number of coins in Curve LP token + */ + constructor( + address _poolToken, + address _poolMinter, + bool _isCurveV1, + uint256 _coinCount + ) public { + 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).approve(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"); + + for (uint256 i = 0 ; i < coinCount ; ++i) { + IERC20(coins[i]).transferFrom(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(coinCount == _minAmountsOut.length, "invalid amounts in"); + + IERC20(_pool).transferFrom(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 _mintTokenout, + address _destination + ) external { + require(poolToken == _pool, "invalid pool address"); + + IERC20(_pool).transferFrom(msg.sender, address(this), _liquidity); + + if (isCurveV1) { + ICurveV1(poolMinter).remove_liquidity_one_coin(_liquidity, int128(int256(_coinIndex)), _mintTokenout); + } else { + ICurveV2(poolMinter).remove_liquidity_one_coin(_liquidity, _coinIndex, _mintTokenout); + } + + _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("addLiquidity(address,uint256[],uint256,address)", + _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); + for (uint256 i = 0 ; i < coinCount ; ++i) { + if (_component == coins[i]) { + amountsIn[i] = _maxTokenIn; + } + } + + target = address(this); + value = 0; + data = abi.encodeWithSignature("addLiquidity(address,uint256[],uint256,address)", + _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("removeLiquidity(address,uint256,uint256[],address)", + _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("removeLiquidityOneCoin(address,uint256,uint256,uint256,address)", + _pool, + _liquidity, + coinIndex[_component], + _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 || 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 LP 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).transfer(recipient, IERC20(token).balanceOf(address(this))); + } +} \ No newline at end of file From c3e1cfdf5a2b2c4264743e65c95e2b0400a3dec4 Mon Sep 17 00:00:00 2001 From: deephil0226 Date: Mon, 11 Jul 2022 21:42:42 -0400 Subject: [PATCH 2/9] unit tests for CurveAmmAdapter --- .../integration/amm/CurveAmmAdapter.sol | 22 +- .../integration/amm/CurveAmmAdapter.spec.ts | 646 ++++++++++++++++++ utils/contracts/index.ts | 1 + utils/deploys/deployAdapters.ts | 16 + 4 files changed, 675 insertions(+), 10 deletions(-) create mode 100644 test/protocol/integration/amm/CurveAmmAdapter.spec.ts diff --git a/contracts/protocol/integration/amm/CurveAmmAdapter.sol b/contracts/protocol/integration/amm/CurveAmmAdapter.sol index 0983dabb0..d7e81e201 100644 --- a/contracts/protocol/integration/amm/CurveAmmAdapter.sol +++ b/contracts/protocol/integration/amm/CurveAmmAdapter.sol @@ -22,6 +22,7 @@ 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"; @@ -36,6 +37,7 @@ import "../../../interfaces/external/curve/ICurveV2.sol"; */ contract CurveAmmAdapter is IAmmAdapter { using SafeMath for uint256; + using SafeERC20 for IERC20; /* ============ State Variables ============ */ @@ -72,7 +74,7 @@ contract CurveAmmAdapter is IAmmAdapter { coins.push(coin); coinIndex[coin] = i.add(1); - IERC20(coin).approve(address(_poolMinter), type(uint256).max); + IERC20(coin).safeApprove(address(_poolMinter), type(uint256).max); } } @@ -86,7 +88,7 @@ contract CurveAmmAdapter is IAmmAdapter { require(coinCount == _amountsIn.length, "invalid amounts in"); for (uint256 i = 0 ; i < coinCount ; ++i) { - IERC20(coins[i]).transferFrom(msg.sender, address(this), _amountsIn[i]); + IERC20(coins[i]).safeTransferFrom(msg.sender, address(this), _amountsIn[i]); } if (coinCount == 2) { @@ -111,9 +113,9 @@ contract CurveAmmAdapter is IAmmAdapter { address _destination ) external { require(poolToken == _pool, "invalid pool address"); - require(coinCount == _minAmountsOut.length, "invalid amounts in"); + require(coinCount == _minAmountsOut.length, "invalid amounts out"); - IERC20(_pool).transferFrom(msg.sender, address(this), _liquidity); + IERC20(_pool).safeTransferFrom(msg.sender, address(this), _liquidity); if (coinCount == 2) { ICurveMinter(poolMinter).remove_liquidity(_liquidity, [_minAmountsOut[0], _minAmountsOut[1]]); @@ -136,17 +138,17 @@ contract CurveAmmAdapter is IAmmAdapter { address _pool, uint256 _liquidity, uint256 _coinIndex, - uint256 _mintTokenout, + uint256 _minTokenout, address _destination ) external { require(poolToken == _pool, "invalid pool address"); - IERC20(_pool).transferFrom(msg.sender, address(this), _liquidity); + IERC20(_pool).safeTransferFrom(msg.sender, address(this), _liquidity); if (isCurveV1) { - ICurveV1(poolMinter).remove_liquidity_one_coin(_liquidity, int128(int256(_coinIndex)), _mintTokenout); + ICurveV1(poolMinter).remove_liquidity_one_coin(_liquidity, int128(int256(_coinIndex)), _minTokenout); } else { - ICurveV2(poolMinter).remove_liquidity_one_coin(_liquidity, _coinIndex, _mintTokenout); + ICurveV2(poolMinter).remove_liquidity_one_coin(_liquidity, _coinIndex, _minTokenout); } _transferToken(coins[_coinIndex], _destination); @@ -307,7 +309,7 @@ contract CurveAmmAdapter is IAmmAdapter { data = abi.encodeWithSignature("removeLiquidityOneCoin(address,uint256,uint256,uint256,address)", _pool, _liquidity, - coinIndex[_component], + coinIndex[_component].sub(1), _minTokenOut, _setToken ); @@ -376,6 +378,6 @@ contract CurveAmmAdapter is IAmmAdapter { address token, address recipient ) internal { - IERC20(token).transfer(recipient, IERC20(token).balanceOf(address(this))); + IERC20(token).safeTransfer(recipient, IERC20(token).balanceOf(address(this))); } } \ No newline at end of file diff --git a/test/protocol/integration/amm/CurveAmmAdapter.spec.ts b/test/protocol/integration/amm/CurveAmmAdapter.spec.ts new file mode 100644 index 000000000..716beffb4 --- /dev/null +++ b/test/protocol/integration/amm/CurveAmmAdapter.spec.ts @@ -0,0 +1,646 @@ +import "module-alias/register"; + +import { ethers, network } from "hardhat"; +import { Address } from "@utils/types"; +import { Account } from "@utils/test/types"; +import { AmmModule } 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 { ICurveMinter__factory } from "../../../../typechain/factories/ICurveMinter__factory"; +import { IERC20__factory } from "../../../../typechain/factories/IERC20__factory"; + +const expect = getWaffleExpect(); + +const getTokenFromWhale = async ( + token: IERC20, + 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); +}; + +const getReserves = async (poolMinter: ICurveMinter, coinCount: number): Promise => { + const balances = []; + for (let i = 0; i < coinCount; i++) { + balances.push(await poolMinter.balances(i)); + } + return balances; +}; + +describe("CurveAmmAdapter [ @forked-mainnet ]", () => { + let owner: Account; + let deployer: DeployHelper; + let setup: SystemFixture; + let ammModule: AmmModule; + + before(async () => { + await network.provider.request({ + method: "hardhat_reset", + params: [ + { + forking: { + jsonRpcUrl: `https://eth-mainnet.alchemyapi.io/v2/${process.env.ALCHEMY_TOKEN}`, + blockNumber: 15118000, + }, + }, + ], + }); + + [owner] = 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); + }); + + after(async () => { + await network.provider.request({ + method: "hardhat_reset", + params: [], + }); + }); + + 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: IERC20[] = []; + let poolToken: IERC20; + let poolMinter: ICurveMinter; + let curveAmmAdapter: CurveAmmAdapter; + let curveAmmAdapterName: string; + + before(async () => { + poolMinter = await ICurveMinter__factory.connect(poolMinterAddress, owner.wallet); + + poolToken = await IERC20__factory.connect(poolTokenAddress, owner.wallet); + await getTokenFromWhale( + poolToken, + poolTokenWhale, + owner, + await poolToken.balanceOf(poolTokenWhale), + ); + for (let i = 0; i < coinCount; i++) { + coins.push(await IERC20__factory.connect(coinAddresses[i], owner.wallet)); + + await getTokenFromWhale( + coins[i], + coinWhales[i], + owner, + await coins[i].balanceOf(coinWhales[i]), + ); + } + + curveAmmAdapter = await deployer.adapters.deployCurveAmmAdapter( + poolTokenAddress, + poolMinterAddress, + isCurveV1, + coinCount, + ); + curveAmmAdapterName = "CURVEAMM" + scenarioName; + + 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); + }); + + 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 () => { + expect(await curveAmmAdapter.isValidPool(poolTokenAddress, coinAddresses)).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(poolMinter, coinCount); + totalSupply = await poolToken.totalSupply(); + + subjectAmmPool = poolTokenAddress; + subjectComponents = coinAddresses; + subjectMaxTokensIn = reserves.map(balance => balance.div(100)); + subjectMinLiquidity = totalSupply.div(100); + }); + + async function subject(): Promise { + 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(poolMinter, coinCount); + totalSupply = await poolToken.totalSupply(); + + subjectAmmPool = poolTokenAddress; + subjectComponent = coinAddresses[1]; + subjectMaxTokenIn = reserves[1].div(100); + subjectMinLiquidity = totalSupply.div(100); + }); + + async function subject(): Promise { + 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(poolMinter, coinCount); + totalSupply = await poolToken.totalSupply(); + + subjectAmmPool = poolTokenAddress; + subjectComponents = coinAddresses; + subjectLiquidity = await poolToken.balanceOf(owner.address); + subjectMinTokensOut = reserves.map(balance => + balance.mul(subjectLiquidity).div(totalSupply), + ); + }); + + async function subject(): Promise { + 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(poolMinter, coinCount); + totalSupply = await poolToken.totalSupply(); + + subjectAmmPool = poolTokenAddress; + subjectComponent = coinAddresses[1]; + subjectLiquidity = await poolToken.balanceOf(owner.address); + subjectMinTokenOut = reserves[1].mul(subjectLiquidity).div(totalSupply); + }); + + async function subject(): Promise { + 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 reserves: BigNumber[]; + + beforeEach(async () => { + reserves = await getReserves(poolMinter, coinCount); + + subjectAmmPool = poolTokenAddress; + subjectMaxTokensIn = reserves.map(balance => balance.div(100)); + subjectMinLiquidity = BigNumber.from(0); + + for (let i = 0; i < coinCount; i++) { + await coins[i].approve(curveAmmAdapter.address, subjectMaxTokensIn[i]); + } + }); + + async function subject(): Promise { + return await curveAmmAdapter.addLiquidity( + subjectAmmPool, + subjectMaxTokensIn, + subjectMinLiquidity, + owner.address, + ); + } + + 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 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]); + } + }); + }); + + describe("#removeLiquidity", () => { + let subjectAmmPool: Address; + let subjectLiquidity: BigNumber; + let subjectMinAmountsOut: BigNumber[]; + + beforeEach(async () => { + subjectAmmPool = poolTokenAddress; + subjectMinAmountsOut = Array(coinCount).fill(0); + subjectLiquidity = await poolToken.balanceOf(owner.address); + + await poolToken.approve(curveAmmAdapter.address, subjectLiquidity); + }); + + async function subject(): Promise { + return await curveAmmAdapter.removeLiquidity( + subjectAmmPool, + subjectLiquidity, + subjectMinAmountsOut, + owner.address, + ); + } + + 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 () => { + subjectMinAmountsOut = []; + await expect(subject()).to.revertedWith("invalid amounts"); + }); + + 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]); + } + }); + }); + + describe("#removeLiquidityOneCoin", () => { + let subjectAmmPool: Address; + let subjectLiquidity: BigNumber; + let subjectCoinIndex: number; + let subjectMinTokenOut: BigNumber; + + beforeEach(async () => { + subjectAmmPool = poolTokenAddress; + subjectLiquidity = await poolToken.balanceOf(owner.address); + subjectCoinIndex = 1; + subjectMinTokenOut = BigNumber.from(0); + + await poolToken.approve(curveAmmAdapter.address, subjectLiquidity); + }); + + async function subject(): Promise { + return await curveAmmAdapter.removeLiquidityOneCoin( + subjectAmmPool, + subjectLiquidity, + subjectCoinIndex, + subjectMinTokenOut, + owner.address, + ); + } + + it("should revert if invalid pool address", async () => { + subjectAmmPool = ADDRESS_ZERO; + await expect(subject()).to.revertedWith("invalid pool address"); + }); + + 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]); + } + } + }); + }); + }); + }; + + 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 + "0xf584F8728B874a6a5c7A8d4d387C9aae9172D621", // WBTC whale + "0x06920C9fC643De77B99cB7670A944AD31eaAA260", // WETH whale + ], + }, + ]; + + testScenarios.forEach(scenario => runTestScenarioForCurveLP(scenario)); +}); 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 { From bd2ecedeae6b41c9c67308f5145b03160bbff27c Mon Sep 17 00:00:00 2001 From: deephil0226 Date: Thu, 14 Jul 2022 11:55:57 -0400 Subject: [PATCH 3/9] add CurveAmmAdapter integration test with AmmModule --- .../external/curve/ICurveMinter.sol | 18 ++ .../integration/amm/CurveAmmAdapter.sol | 17 +- test/integration/curveAmmModule.spec.ts | 294 ++++++++++++++++++ .../integration/amm/CurveAmmAdapter.spec.ts | 8 + 4 files changed, 333 insertions(+), 4 deletions(-) create mode 100644 test/integration/curveAmmModule.spec.ts diff --git a/contracts/interfaces/external/curve/ICurveMinter.sol b/contracts/interfaces/external/curve/ICurveMinter.sol index 0d1c8250b..7862541de 100644 --- a/contracts/interfaces/external/curve/ICurveMinter.sol +++ b/contracts/interfaces/external/curve/ICurveMinter.sol @@ -23,4 +23,22 @@ interface ICurveMinter { 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/protocol/integration/amm/CurveAmmAdapter.sol b/contracts/protocol/integration/amm/CurveAmmAdapter.sol index d7e81e201..893599a42 100644 --- a/contracts/protocol/integration/amm/CurveAmmAdapter.sol +++ b/contracts/protocol/integration/amm/CurveAmmAdapter.sol @@ -338,14 +338,23 @@ contract CurveAmmAdapter is IAmmAdapter { view override returns (bool) { - if (poolToken != _pool || coinCount != _components.length) { + if (poolToken != _pool) { return false; } - - for (uint256 i = 0 ; i < coinCount ; ++i) { - if (coins[i] != _components[i]) { + + 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; diff --git a/test/integration/curveAmmModule.spec.ts b/test/integration/curveAmmModule.spec.ts new file mode 100644 index 000000000..e547f9381 --- /dev/null +++ b/test/integration/curveAmmModule.spec.ts @@ -0,0 +1,294 @@ +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 } from "ethers"; +import { CurveAmmAdapter } from "../../typechain/CurveAmmAdapter"; +import { IERC20Metadata } from "../../typechain/IERC20Metadata"; +import { IERC20Metadata__factory } from "../../typechain/factories/IERC20Metadata__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 () => { + await network.provider.request({ + method: "hardhat_reset", + params: [ + { + forking: { + jsonRpcUrl: `https://eth-mainnet.alchemyapi.io/v2/${process.env.ALCHEMY_TOKEN}`, + blockNumber: 15118000, + }, + }, + ], + }); + + [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); + }); + + after(async () => { + await network.provider.request({ + method: "hardhat_reset", + params: [], + }); + }); + + 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 poolToken: IERC20Metadata; + let curveAmmAdapter: CurveAmmAdapter; + let curveAmmAdapterName: string; + const coinBalances: BigNumber[] = []; + + before(async () => { + // 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(); + + it.only("should transfer correct components and get LP tokens", async () => { + const balanceBefore = await poolToken.balanceOf(setToken.address); + + await ammModule + .connect(manager.wallet) + .addLiquidity( + setToken.address, + curveAmmAdapterName, + poolTokenAddress, + 1, + coinAddresses, + coinBalances, + ); + + expect(await poolToken.balanceOf(setToken.address)).to.gt(balanceBefore); + }); + + it.only("should transfer LP tokens and get component tokens", async () => { + await ammModule + .connect(manager.wallet) + .addLiquidity( + setToken.address, + curveAmmAdapterName, + poolTokenAddress, + 1, + coinAddresses, + coinBalances, + ); + + 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 ammModule + .connect(manager.wallet) + .removeLiquidity( + setToken.address, + curveAmmAdapterName, + poolTokenAddress, + lpBalanceBefore, + coinAddresses, + Array(coinCount).fill(1), + ); + + expect(await poolToken.balanceOf(setToken.address)).to.eq(0); + for (let i = 0; i < coinCount; i++) { + expect(await coins[i].balanceOf(setToken.address)).to.gt(0); + } + }); + + it.only("should transfer LP tokens and get only one component token", async () => { + await ammModule + .connect(manager.wallet) + .addLiquidity( + setToken.address, + curveAmmAdapterName, + poolTokenAddress, + 1, + coinAddresses, + coinBalances, + ); + + const lpBalanceBefore = await poolToken.balanceOf(setToken.address); + for (let i = 0; i < coinCount; i++) { + expect(await coins[i].balanceOf(setToken.address)).to.eq(0); + } + + const withdrawCoin = coinAddresses[1]; + await ammModule + .connect(manager.wallet) + .removeLiquiditySingleAsset( + setToken.address, + curveAmmAdapterName, + poolTokenAddress, + lpBalanceBefore, + withdrawCoin, + 1, + ); + + expect(await poolToken.balanceOf(setToken.address)).to.eq(0); + for (let i = 0; i < coinCount; i++) { + if (coinAddresses[i] === withdrawCoin) { + 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 + "0xf584F8728B874a6a5c7A8d4d387C9aae9172D621", // WBTC whale + "0x06920C9fC643De77B99cB7670A944AD31eaAA260", // 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 index 716beffb4..f3f5b45de 100644 --- a/test/protocol/integration/amm/CurveAmmAdapter.spec.ts +++ b/test/protocol/integration/amm/CurveAmmAdapter.spec.ts @@ -197,6 +197,7 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { 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 () => { @@ -210,7 +211,14 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { }); 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, + ); + } }); }); From 4603f7f1cc8345306e1e56ba18dbf2db99626a9c Mon Sep 17 00:00:00 2001 From: deephil0226 Date: Thu, 14 Jul 2022 12:47:55 -0400 Subject: [PATCH 4/9] fix comments --- .../integration/amm/CurveAmmAdapter.sol | 52 +++++++++++++------ .../integration/amm/CurveAmmAdapter.spec.ts | 46 +++++++++++----- 2 files changed, 68 insertions(+), 30 deletions(-) diff --git a/contracts/protocol/integration/amm/CurveAmmAdapter.sol b/contracts/protocol/integration/amm/CurveAmmAdapter.sol index 893599a42..0568095e3 100644 --- a/contracts/protocol/integration/amm/CurveAmmAdapter.sol +++ b/contracts/protocol/integration/amm/CurveAmmAdapter.sol @@ -1,5 +1,5 @@ /* - Copyright 2021 Set Labs Inc. + 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. @@ -33,7 +33,7 @@ import "../../../interfaces/external/curve/ICurveV2.sol"; * @title CurveAmmAdapter * @author deephil * - * Adapter for Curve that encodes adding and removing liquidty + * Adapter for Curve that encodes functions for adding and removing liquidity */ contract CurveAmmAdapter is IAmmAdapter { using SafeMath for uint256; @@ -41,23 +41,43 @@ contract CurveAmmAdapter is IAmmAdapter { /* ============ 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; - mapping(address => uint256) public coinIndex; // starts from 1 + + // Coin Index of Curve Pool (starts from 1) + mapping(address => uint256) public coinIndex; /* ============ Constructor ============ */ /** * Set state variables * - * @param _poolToken Address of Curve LP token - * @param _poolMinter Address of Curve LP token minter + * @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 LP token + * @param _coinCount Number of coins in Curve Pool token */ constructor( address _poolToken, @@ -184,7 +204,8 @@ contract CurveAmmAdapter is IAmmAdapter { target = address(this); value = 0; - data = abi.encodeWithSignature("addLiquidity(address,uint256[],uint256,address)", + data = abi.encodeWithSignature( + ADD_LIQUIDITY, _pool, maxTokensIn, _minLiquidity, @@ -212,15 +233,12 @@ contract CurveAmmAdapter is IAmmAdapter { require(_maxTokenIn != 0, "invalid component amount"); uint256[] memory amountsIn = new uint256[](coinCount); - for (uint256 i = 0 ; i < coinCount ; ++i) { - if (_component == coins[i]) { - amountsIn[i] = _maxTokenIn; - } - } + amountsIn[coinIndex[_component].sub(1)] = _maxTokenIn; target = address(this); value = 0; - data = abi.encodeWithSignature("addLiquidity(address,uint256[],uint256,address)", + data = abi.encodeWithSignature( + ADD_LIQUIDITY, _pool, amountsIn, _minLiquidity, @@ -272,7 +290,8 @@ contract CurveAmmAdapter is IAmmAdapter { target = address(this); value = 0; - data = abi.encodeWithSignature("removeLiquidity(address,uint256,uint256[],address)", + data = abi.encodeWithSignature( + REMOVE_LIQUIDITY, _pool, _liquidity, minTokensOut, @@ -306,7 +325,8 @@ contract CurveAmmAdapter is IAmmAdapter { target = address(this); value = 0; - data = abi.encodeWithSignature("removeLiquidityOneCoin(address,uint256,uint256,uint256,address)", + data = abi.encodeWithSignature( + REMOVE_LIQUIDITY_ONE_COIN, _pool, _liquidity, coinIndex[_component].sub(1), @@ -363,7 +383,7 @@ contract CurveAmmAdapter is IAmmAdapter { /* ============ Internal Functions =================== */ /** - * Returns the Curve LP token reserves in an expected order + * Returns the Curve Pool token reserves in an expected order */ function _getReserves() internal diff --git a/test/protocol/integration/amm/CurveAmmAdapter.spec.ts b/test/protocol/integration/amm/CurveAmmAdapter.spec.ts index f3f5b45de..1fabb6b06 100644 --- a/test/protocol/integration/amm/CurveAmmAdapter.spec.ts +++ b/test/protocol/integration/amm/CurveAmmAdapter.spec.ts @@ -240,7 +240,7 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { subjectMinLiquidity = totalSupply.div(100); }); - async function subject(): Promise { + const subject = async () => { return await curveAmmAdapter.getProvideLiquidityCalldata( owner.address, subjectAmmPool, @@ -248,7 +248,7 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { subjectMaxTokensIn, subjectMinLiquidity, ); - } + }; it("should return the correct provide liquidity calldata", async () => { const calldata = await subject(); @@ -294,7 +294,7 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { subjectMinLiquidity = totalSupply.div(100); }); - async function subject(): Promise { + const subject = async () => { return await curveAmmAdapter.getProvideLiquiditySingleAssetCalldata( owner.address, subjectAmmPool, @@ -302,7 +302,7 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { subjectMaxTokenIn, subjectMinLiquidity, ); - } + }; it("should return the correct provide liquidity calldata", async () => { const calldata = await subject(); @@ -357,7 +357,7 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { ); }); - async function subject(): Promise { + const subject = async () => { return await curveAmmAdapter.getRemoveLiquidityCalldata( owner.address, subjectAmmPool, @@ -365,7 +365,7 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { subjectMinTokensOut, subjectLiquidity, ); - } + }; it("should return the correct provide liquidity calldata", async () => { const calldata = await subject(); @@ -421,7 +421,7 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { subjectMinTokenOut = reserves[1].mul(subjectLiquidity).div(totalSupply); }); - async function subject(): Promise { + const subject = async () => { return await curveAmmAdapter.getRemoveLiquiditySingleAssetCalldata( owner.address, subjectAmmPool, @@ -429,7 +429,7 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { subjectMinTokenOut, subjectLiquidity, ); - } + }; it("should return the correct provide liquidity calldata", async () => { const calldata = await subject(); @@ -478,14 +478,14 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { } }); - async function subject(): Promise { + const subject = async () => { return await curveAmmAdapter.addLiquidity( subjectAmmPool, subjectMaxTokensIn, subjectMinLiquidity, owner.address, ); - } + }; it("should revert if invalid pool address", async () => { subjectAmmPool = ADDRESS_ZERO; @@ -510,6 +510,12 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { 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); + } }); }); @@ -526,14 +532,14 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { await poolToken.approve(curveAmmAdapter.address, subjectLiquidity); }); - async function subject(): Promise { + const subject = async () => { return await curveAmmAdapter.removeLiquidity( subjectAmmPool, subjectLiquidity, subjectMinAmountsOut, owner.address, ); - } + }; it("should revert if invalid pool address", async () => { subjectAmmPool = ADDRESS_ZERO; @@ -558,6 +564,12 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { 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); + } }); }); @@ -576,7 +588,7 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { await poolToken.approve(curveAmmAdapter.address, subjectLiquidity); }); - async function subject(): Promise { + const subject = async () => { return await curveAmmAdapter.removeLiquidityOneCoin( subjectAmmPool, subjectLiquidity, @@ -584,7 +596,7 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { subjectMinTokenOut, owner.address, ); - } + }; it("should revert if invalid pool address", async () => { subjectAmmPool = ADDRESS_ZERO; @@ -608,6 +620,12 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { 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); + } }); }); }); From 5a5100e7d2f26a0d998cb976d1f74142d5997661 Mon Sep 17 00:00:00 2001 From: deephil0226 Date: Mon, 18 Jul 2022 12:03:12 -0400 Subject: [PATCH 5/9] fix comments --- .../integration/amm/CurveAmmAdapter.sol | 11 +++++- .../integration/amm/CurveAmmAdapter.spec.ts | 39 +++++++++++++++++-- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/contracts/protocol/integration/amm/CurveAmmAdapter.sol b/contracts/protocol/integration/amm/CurveAmmAdapter.sol index 0568095e3..7ea45d7b1 100644 --- a/contracts/protocol/integration/amm/CurveAmmAdapter.sol +++ b/contracts/protocol/integration/amm/CurveAmmAdapter.sol @@ -106,10 +106,17 @@ contract CurveAmmAdapter is IAmmAdapter { ) 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"); - for (uint256 i = 0 ; i < coinCount ; ++i) { + bool isValidAmountsIn = false; + for (uint256 i = 0; i < coinCount; ++i) { IERC20(coins[i]).safeTransferFrom(msg.sender, address(this), _amountsIn[i]); + if (_amountsIn[i] > 0) { + isValidAmountsIn = true; + } } + require(isValidAmountsIn, "invalid amounts in"); if (coinCount == 2) { ICurveMinter(poolMinter).add_liquidity([_amountsIn[0], _amountsIn[1]], _minLiquidity); @@ -134,6 +141,7 @@ contract CurveAmmAdapter is IAmmAdapter { ) external { require(poolToken == _pool, "invalid pool address"); require(coinCount == _minAmountsOut.length, "invalid amounts out"); + require(_destination != address(0), "invalid destination"); IERC20(_pool).safeTransferFrom(msg.sender, address(this), _liquidity); @@ -162,6 +170,7 @@ contract CurveAmmAdapter is IAmmAdapter { address _destination ) external { require(poolToken == _pool, "invalid pool address"); + require(_destination != address(0), "invalid destination"); IERC20(_pool).safeTransferFrom(msg.sender, address(this), _liquidity); diff --git a/test/protocol/integration/amm/CurveAmmAdapter.spec.ts b/test/protocol/integration/amm/CurveAmmAdapter.spec.ts index 1fabb6b06..ceba80507 100644 --- a/test/protocol/integration/amm/CurveAmmAdapter.spec.ts +++ b/test/protocol/integration/amm/CurveAmmAdapter.spec.ts @@ -464,6 +464,7 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { let subjectAmmPool: Address; let subjectMaxTokensIn: BigNumber[]; let subjectMinLiquidity: BigNumber; + let subjectDestination: Address; let reserves: BigNumber[]; beforeEach(async () => { @@ -471,7 +472,8 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { subjectAmmPool = poolTokenAddress; subjectMaxTokensIn = reserves.map(balance => balance.div(100)); - subjectMinLiquidity = BigNumber.from(0); + subjectMinLiquidity = BigNumber.from(1); + subjectDestination = owner.address; for (let i = 0; i < coinCount; i++) { await coins[i].approve(curveAmmAdapter.address, subjectMaxTokensIn[i]); @@ -483,7 +485,7 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { subjectAmmPool, subjectMaxTokensIn, subjectMinLiquidity, - owner.address, + subjectDestination, ); }; @@ -497,6 +499,21 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { 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 = []; @@ -523,11 +540,13 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { 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); }); @@ -537,7 +556,7 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { subjectAmmPool, subjectLiquidity, subjectMinAmountsOut, - owner.address, + subjectDestination, ); }; @@ -551,6 +570,11 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { 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 = []; @@ -578,12 +602,14 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { 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(0); + subjectDestination = owner.address; await poolToken.approve(curveAmmAdapter.address, subjectLiquidity); }); @@ -594,7 +620,7 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { subjectLiquidity, subjectCoinIndex, subjectMinTokenOut, - owner.address, + subjectDestination, ); }; @@ -603,6 +629,11 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { await expect(subject()).to.revertedWith("invalid pool address"); }); + 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 = []; From 1472ad41fff4949d15c3a1853143874d6f2de4d4 Mon Sep 17 00:00:00 2001 From: deephil0226 Date: Wed, 20 Jul 2022 14:57:34 -0400 Subject: [PATCH 6/9] fix comments --- .../integration/amm/CurveAmmAdapter.sol | 13 +- test/integration/curveAmmModule.spec.ts | 257 ++++++++++++------ .../integration/amm/CurveAmmAdapter.spec.ts | 22 +- 3 files changed, 214 insertions(+), 78 deletions(-) diff --git a/contracts/protocol/integration/amm/CurveAmmAdapter.sol b/contracts/protocol/integration/amm/CurveAmmAdapter.sol index 7ea45d7b1..59470a547 100644 --- a/contracts/protocol/integration/amm/CurveAmmAdapter.sol +++ b/contracts/protocol/integration/amm/CurveAmmAdapter.sol @@ -85,6 +85,10 @@ contract CurveAmmAdapter is IAmmAdapter { 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; @@ -111,13 +115,16 @@ contract CurveAmmAdapter is IAmmAdapter { bool isValidAmountsIn = false; for (uint256 i = 0; i < coinCount; ++i) { - IERC20(coins[i]).safeTransferFrom(msg.sender, address(this), _amountsIn[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); } @@ -140,6 +147,7 @@ contract CurveAmmAdapter is IAmmAdapter { 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"); @@ -170,6 +178,9 @@ contract CurveAmmAdapter is IAmmAdapter { 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); diff --git a/test/integration/curveAmmModule.spec.ts b/test/integration/curveAmmModule.spec.ts index e547f9381..97ee59cf6 100644 --- a/test/integration/curveAmmModule.spec.ts +++ b/test/integration/curveAmmModule.spec.ts @@ -13,10 +13,12 @@ import { } from "@utils/test/index"; import { ether } from "@utils/index"; import { SystemFixture } from "@utils/fixtures"; -import { BigNumber } from "ethers"; +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(); @@ -100,12 +102,14 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { }) => { 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( @@ -161,94 +165,195 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { addSnapshotBeforeRestoreAfterEach(); - it.only("should transfer correct components and get LP tokens", async () => { - const balanceBefore = await poolToken.balanceOf(setToken.address); + 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]); + } - await ammModule - .connect(manager.wallet) - .addLiquidity( - setToken.address, - curveAmmAdapterName, - poolTokenAddress, - 1, - coinAddresses, - coinBalances, + const expectedNewLpTokens = + coinCount === 2 + ? await poolMinter["calc_token_amount(uint256[2],bool)"]( + [coinBalances[0], coinBalances[1]], + true, + ) + : coinCount === 3 + ? await poolMinter["calc_token_amount(uint256[3],bool)"]( + [coinBalances[0], coinBalances[1], coinBalances[2]], + true, + ) + : coinCount === 4 + ? await poolMinter["calc_token_amount(uint256[4],bool)"]( + [coinBalances[0], coinBalances[1], coinBalances[2], coinBalances[3]], + true, + ) + : BigNumber.from(0); + + 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 + expectCloseTo( + (await poolToken.balanceOf(setToken.address)).sub(lpBalanceBefore), + expectedNewLpTokens, + expectedNewLpTokens.div(1000), // 0.1% ); - - expect(await poolToken.balanceOf(setToken.address)).to.gt(balanceBefore); + for (let i = 0; i < coinCount; i++) { + expect(await coins[i].balanceOf(setToken.address)).to.eq(0); + } + }); }); - it.only("should transfer LP tokens and get component tokens", async () => { - await ammModule - .connect(manager.wallet) - .addLiquidity( - setToken.address, - curveAmmAdapterName, - poolTokenAddress, - 1, - coinAddresses, - coinBalances, - ); + describe("#removeLiquidity", () => { + let subjectSetToken: Address; + let subjectAmmAdapterName: string; + let subjectPoolToken: Address; + let subjectPoolTokenPositionUnits: BigNumber; + let subjectComponents: Address[]; + let subjectMinComponentUnitsReceived: BigNumber[]; - const lpBalanceBefore = await poolToken.balanceOf(setToken.address); - for (let i = 0; i < coinCount; i++) { - expect(await coins[i].balanceOf(setToken.address)).to.eq(0); - } + 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 ammModule - .connect(manager.wallet) - .removeLiquidity( - setToken.address, - curveAmmAdapterName, - poolTokenAddress, - lpBalanceBefore, - coinAddresses, - Array(coinCount).fill(1), - ); + await subject(); - expect(await poolToken.balanceOf(setToken.address)).to.eq(0); - for (let i = 0; i < coinCount; i++) { - expect(await coins[i].balanceOf(setToken.address)).to.gt(0); - } + 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); + } + }); }); - it.only("should transfer LP tokens and get only one component token", async () => { - await ammModule - .connect(manager.wallet) - .addLiquidity( - setToken.address, - curveAmmAdapterName, - poolTokenAddress, - 1, - coinAddresses, - coinBalances, - ); + describe("removeLiquiditySingleAsset", () => { + let subjectSetToken: Address; + let subjectAmmAdapterName: string; + let subjectPoolToken: Address; + let subjectPoolTokenPositionUnits: BigNumber; + let subjectComponent: Address; + let subjectMinComponentUnitReceived: BigNumber; - const lpBalanceBefore = await poolToken.balanceOf(setToken.address); - for (let i = 0; i < coinCount; i++) { - expect(await coins[i].balanceOf(setToken.address)).to.eq(0); - } + 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); + } - const withdrawCoin = coinAddresses[1]; - await ammModule - .connect(manager.wallet) - .removeLiquiditySingleAsset( - setToken.address, - curveAmmAdapterName, - poolTokenAddress, - lpBalanceBefore, - withdrawCoin, - 1, - ); + await subject(); - expect(await poolToken.balanceOf(setToken.address)).to.eq(0); - for (let i = 0; i < coinCount; i++) { - if (coinAddresses[i] === withdrawCoin) { - expect(await coins[i].balanceOf(setToken.address)).to.gt(0); - } else { - expect(await coins[i].balanceOf(setToken.address)).to.eq(0); + 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); + } } - } + }); }); }); }; diff --git a/test/protocol/integration/amm/CurveAmmAdapter.spec.ts b/test/protocol/integration/amm/CurveAmmAdapter.spec.ts index ceba80507..1870bf507 100644 --- a/test/protocol/integration/amm/CurveAmmAdapter.spec.ts +++ b/test/protocol/integration/amm/CurveAmmAdapter.spec.ts @@ -565,6 +565,11 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { 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"); @@ -608,7 +613,7 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { subjectAmmPool = poolTokenAddress; subjectLiquidity = await poolToken.balanceOf(owner.address); subjectCoinIndex = 1; - subjectMinTokenOut = BigNumber.from(0); + subjectMinTokenOut = BigNumber.from(1); subjectDestination = owner.address; await poolToken.approve(curveAmmAdapter.address, subjectLiquidity); @@ -629,6 +634,21 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { 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"); From 5cf916f1feaa67391f496b7560e4788881a88e09 Mon Sep 17 00:00:00 2001 From: deephil0226 Date: Wed, 20 Jul 2022 15:00:14 -0400 Subject: [PATCH 7/9] fix comments --- contracts/protocol/integration/amm/CurveAmmAdapter.sol | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/contracts/protocol/integration/amm/CurveAmmAdapter.sol b/contracts/protocol/integration/amm/CurveAmmAdapter.sol index 59470a547..14662cd20 100644 --- a/contracts/protocol/integration/amm/CurveAmmAdapter.sol +++ b/contracts/protocol/integration/amm/CurveAmmAdapter.sol @@ -165,7 +165,7 @@ contract CurveAmmAdapter is IAmmAdapter { revert("curve supports 2/3/4 coins"); } - for (uint256 i = 0 ; i < coinCount ; ++i) { + for (uint256 i = 0; i < coinCount; ++i) { _transferToken(coins[i], _destination); } } @@ -302,7 +302,7 @@ contract CurveAmmAdapter is IAmmAdapter { // Check minTokensOut parameter uint256 totalSupply = IERC20(_pool).totalSupply(); uint256[] memory reserves = _getReserves(); - for (uint256 i = 0 ; i < coinCount ; ++i) { + for (uint256 i = 0; i < coinCount; ++i) { uint256 reservesOwnedByLiquidity = reserves[i].mul(_liquidity).div(totalSupply); require(minTokensOut[i] <= reservesOwnedByLiquidity, "amounts must be <= ownedTokens"); } @@ -390,7 +390,7 @@ contract CurveAmmAdapter is IAmmAdapter { if (coinCount != _components.length) { return false; } - for (uint256 i = 0 ; i < coinCount ; ++i) { + for (uint256 i = 0; i < coinCount; ++i) { if (coins[i] != _components[i]) { return false; } @@ -412,7 +412,7 @@ contract CurveAmmAdapter is IAmmAdapter { { reserves = new uint256[](coinCount); - for (uint256 i = 0 ; i < coinCount ; ++i) { + for (uint256 i = 0; i < coinCount; ++i) { reserves[i] = ICurveMinter(poolMinter).balances(i); } } From 87ad60b65adf43e83a182ba62b2bcf06dd2cf3ef Mon Sep 17 00:00:00 2001 From: deephil0226 Date: Wed, 27 Jul 2022 10:42:24 -0400 Subject: [PATCH 8/9] fix comments --- hardhat.config.ts | 2 +- test/integration/curveAmmModule.spec.ts | 56 +++++++------------ .../integration/amm/CurveAmmAdapter.spec.ts | 19 ------- 3 files changed, 22 insertions(+), 55 deletions(-) diff --git a/hardhat.config.ts b/hardhat.config.ts index 11747d102..7ff4cffad 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -12,7 +12,7 @@ import "./tasks"; const forkingConfig = { url: `https://eth-mainnet.alchemyapi.io/v2/${process.env.ALCHEMY_TOKEN}`, - blockNumber: 14792479, + blockNumber: 15118000, }; const mochaConfig = { diff --git a/test/integration/curveAmmModule.spec.ts b/test/integration/curveAmmModule.spec.ts index 97ee59cf6..7acaf7848 100644 --- a/test/integration/curveAmmModule.spec.ts +++ b/test/integration/curveAmmModule.spec.ts @@ -52,18 +52,6 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { let ammModule: AmmModule; before(async () => { - await network.provider.request({ - method: "hardhat_reset", - params: [ - { - forking: { - jsonRpcUrl: `https://eth-mainnet.alchemyapi.io/v2/${process.env.ALCHEMY_TOKEN}`, - blockNumber: 15118000, - }, - }, - ], - }); - [owner, manager] = await getAccounts(); deployer = new DeployHelper(owner.wallet); @@ -74,13 +62,6 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { await setup.controller.addModule(ammModule.address); }); - after(async () => { - await network.provider.request({ - method: "hardhat_reset", - params: [], - }); - }); - const runTestScenarioForCurveLP = ({ scenarioName, poolTokenAddress, @@ -206,30 +187,35 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { expect(await coins[i].balanceOf(setToken.address)).to.eq(subjectCoinBalances[i]); } - const expectedNewLpTokens = - coinCount === 2 - ? await poolMinter["calc_token_amount(uint256[2],bool)"]( + let expectedNewLpTokens = BigNumber.from(0); + switch (coinCount) { + case 2: + expectedNewLpTokens = await poolMinter["calc_token_amount(uint256[2],bool)"]( [coinBalances[0], coinBalances[1]], true, - ) - : coinCount === 3 - ? await poolMinter["calc_token_amount(uint256[3],bool)"]( - [coinBalances[0], coinBalances[1], coinBalances[2]], - true, - ) - : coinCount === 4 - ? await poolMinter["calc_token_amount(uint256[4],bool)"]( - [coinBalances[0], coinBalances[1], coinBalances[2], coinBalances[3]], - true, - ) - : BigNumber.from(0); + ); + 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( - (await poolToken.balanceOf(setToken.address)).sub(lpBalanceBefore), + newLpTokens, expectedNewLpTokens, expectedNewLpTokens.div(1000), // 0.1% ); diff --git a/test/protocol/integration/amm/CurveAmmAdapter.spec.ts b/test/protocol/integration/amm/CurveAmmAdapter.spec.ts index 1870bf507..897f310e5 100644 --- a/test/protocol/integration/amm/CurveAmmAdapter.spec.ts +++ b/test/protocol/integration/amm/CurveAmmAdapter.spec.ts @@ -59,18 +59,6 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { let ammModule: AmmModule; before(async () => { - await network.provider.request({ - method: "hardhat_reset", - params: [ - { - forking: { - jsonRpcUrl: `https://eth-mainnet.alchemyapi.io/v2/${process.env.ALCHEMY_TOKEN}`, - blockNumber: 15118000, - }, - }, - ], - }); - [owner] = await getAccounts(); deployer = new DeployHelper(owner.wallet); @@ -81,13 +69,6 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { await setup.controller.addModule(ammModule.address); }); - after(async () => { - await network.provider.request({ - method: "hardhat_reset", - params: [], - }); - }); - const runTestScenarioForCurveLP = ({ scenarioName, poolTokenAddress, From f533df45f5536ffe1630b54d6878f5186ba9ab5e Mon Sep 17 00:00:00 2001 From: deephil0226 Date: Mon, 1 Aug 2022 07:56:11 -0400 Subject: [PATCH 9/9] fix comments --- .../external/CurveTwoPoolStableswapMock.sol | 82 ++ hardhat.config.ts | 2 +- test/integration/curveAmmModule.spec.ts | 4 +- .../integration/amm/CurveAmmAdapter.spec.ts | 1268 ++++++++--------- 4 files changed, 690 insertions(+), 666 deletions(-) create mode 100644 contracts/mocks/external/CurveTwoPoolStableswapMock.sol 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/hardhat.config.ts b/hardhat.config.ts index 7ff4cffad..11747d102 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -12,7 +12,7 @@ import "./tasks"; const forkingConfig = { url: `https://eth-mainnet.alchemyapi.io/v2/${process.env.ALCHEMY_TOKEN}`, - blockNumber: 15118000, + blockNumber: 14792479, }; const mochaConfig = { diff --git a/test/integration/curveAmmModule.spec.ts b/test/integration/curveAmmModule.spec.ts index 7acaf7848..23ecdf9a4 100644 --- a/test/integration/curveAmmModule.spec.ts +++ b/test/integration/curveAmmModule.spec.ts @@ -375,8 +375,8 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { poolTokenWhale: "0xfE4d9D4F102b40193EeD8aA6C52BD87a328177fc", // Curve USDT/WBTC/WETH whale coinWhales: [ "0x5a52E96BAcdaBb82fd05763E25335261B270Efcb", // USDT whale - "0xf584F8728B874a6a5c7A8d4d387C9aae9172D621", // WBTC whale - "0x06920C9fC643De77B99cB7670A944AD31eaAA260", // WETH whale + "0x2FAF487A4414Fe77e2327F0bf4AE2a264a776AD2", // WBTC whale + "0x56178a0d5F301bAf6CF3e1Cd53d9863437345Bf9", // WETH whale ], }, ]; diff --git a/test/protocol/integration/amm/CurveAmmAdapter.spec.ts b/test/protocol/integration/amm/CurveAmmAdapter.spec.ts index 897f310e5..a3614677b 100644 --- a/test/protocol/integration/amm/CurveAmmAdapter.spec.ts +++ b/test/protocol/integration/amm/CurveAmmAdapter.spec.ts @@ -1,9 +1,8 @@ import "module-alias/register"; -import { ethers, network } from "hardhat"; import { Address } from "@utils/types"; import { Account } from "@utils/test/types"; -import { AmmModule } from "@utils/contracts"; +import { AmmModule, StandardTokenMock } from "@utils/contracts"; import DeployHelper from "@utils/deploys"; import { addSnapshotBeforeRestoreAfterEach, @@ -18,48 +17,42 @@ import { BigNumber } from "ethers"; import { CurveAmmAdapter } from "../../../../typechain/CurveAmmAdapter"; import { ICurveMinter } from "../../../../typechain/ICurveMinter"; import { IERC20 } from "../../../../typechain/IERC20"; -import { ICurveMinter__factory } from "../../../../typechain/factories/ICurveMinter__factory"; +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(); -const getTokenFromWhale = async ( - token: IERC20, - 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); -}; - -const getReserves = async (poolMinter: ICurveMinter, coinCount: number): Promise => { - const balances = []; - for (let i = 0; i < coinCount; i++) { - balances.push(await poolMinter.balances(i)); - } - return balances; -}; - -describe("CurveAmmAdapter [ @forked-mainnet ]", () => { +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] = await getAccounts(); + [owner, liquidityProvider] = await getAccounts(); deployer = new DeployHelper(owner.wallet); setup = getSystemFixture(owner.address); @@ -67,638 +60,587 @@ describe("CurveAmmAdapter [ @forked-mainnet ]", () => { 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, + ); }); - 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: IERC20[] = []; - let poolToken: IERC20; - let poolMinter: ICurveMinter; - let curveAmmAdapter: CurveAmmAdapter; - let curveAmmAdapterName: string; - - before(async () => { - poolMinter = await ICurveMinter__factory.connect(poolMinterAddress, owner.wallet); - - poolToken = await IERC20__factory.connect(poolTokenAddress, owner.wallet); - await getTokenFromWhale( - poolToken, - poolTokenWhale, - owner, - await poolToken.balanceOf(poolTokenWhale), - ); - for (let i = 0; i < coinCount; i++) { - coins.push(await IERC20__factory.connect(coinAddresses[i], owner.wallet)); - - await getTokenFromWhale( - coins[i], - coinWhales[i], - owner, - await coins[i].balanceOf(coinWhales[i]), - ); - } + addSnapshotBeforeRestoreAfterEach(); - curveAmmAdapter = await deployer.adapters.deployCurveAmmAdapter( - poolTokenAddress, - poolMinterAddress, - isCurveV1, - coinCount, - ); - curveAmmAdapterName = "CURVEAMM" + scenarioName; - - 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(poolMinter, coinCount); - 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(poolMinter, coinCount); - 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(poolMinter, coinCount); - 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(poolMinter, coinCount); - 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(poolMinter, coinCount); - - 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); - } - }); - }); + 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"); }); - }; - 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 - "0xf584F8728B874a6a5c7A8d4d387C9aae9172D621", // WBTC whale - "0x06920C9fC643De77B99cB7670A944AD31eaAA260", // WETH whale - ], - }, - ]; - - testScenarios.forEach(scenario => runTestScenarioForCurveLP(scenario)); + 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); + } + }); + }); });