diff --git a/contracts/interfaces/IAmmAdapter.sol b/contracts/interfaces/IAmmAdapter.sol index 13921fff8..8c571c864 100644 --- a/contracts/interfaces/IAmmAdapter.sol +++ b/contracts/interfaces/IAmmAdapter.sol @@ -25,6 +25,7 @@ pragma solidity 0.6.10; interface IAmmAdapter { function getProvideLiquidityCalldata( + address _setToken, address _pool, address[] calldata _components, uint256[] calldata _maxTokensIn, @@ -35,6 +36,7 @@ interface IAmmAdapter { returns (address _target, uint256 _value, bytes memory _calldata); function getProvideLiquiditySingleAssetCalldata( + address _setToken, address _pool, address _component, uint256 _maxTokenIn, @@ -42,6 +44,7 @@ interface IAmmAdapter { ) external view returns (address _target, uint256 _value, bytes memory _calldata); function getRemoveLiquidityCalldata( + address _setToken, address _pool, address[] calldata _components, uint256[] calldata _minTokensOut, @@ -49,6 +52,7 @@ interface IAmmAdapter { ) external view returns (address _target, uint256 _value, bytes memory _calldata); function getRemoveLiquiditySingleAssetCalldata( + address _setToken, address _pool, address _component, uint256 _minTokenOut, @@ -56,5 +60,5 @@ interface IAmmAdapter { ) external view returns (address _target, uint256 _value, bytes memory _calldata); function getSpenderAddress(address _pool) external view returns(address); - function isValidPool(address _pool) external view returns(bool); + function isValidPool(address _pool, address[] memory _components) external view returns(bool); } \ No newline at end of file diff --git a/contracts/mocks/integrations/AmmAdapterMock.sol b/contracts/mocks/integrations/AmmAdapterMock.sol index 33c74ed3b..dffb0a5e9 100644 --- a/contracts/mocks/integrations/AmmAdapterMock.sol +++ b/contracts/mocks/integrations/AmmAdapterMock.sol @@ -85,8 +85,9 @@ contract AmmAdapterMock is ERC20 { /* ============ Adapter Functions ============ */ function getProvideLiquidityCalldata( + address /* _setToken */, address _pool, - address[] calldata /* _components */, + address[] calldata _components, uint256[] calldata _maxTokensIn, uint256 _minLiquidity ) @@ -94,7 +95,7 @@ contract AmmAdapterMock is ERC20 { view returns (address _target, uint256 _value, bytes memory _calldata) { - isValidPool(_pool); + isValidPool(_pool, _components); // Check that components match the pool tokens @@ -103,13 +104,18 @@ contract AmmAdapterMock is ERC20 { } function getProvideLiquiditySingleAssetCalldata( + address /* _setToken */, address _pool, address _component, uint256 _maxTokenIn, uint256 _minLiquidity ) external view returns (address _target, uint256 _value, bytes memory _calldata) { + + address[] memory components = new address[](1); + components[0] = _component; + // This address must be the pool - isValidPool(_pool); + isValidPool(_pool, components); bytes memory callData = abi.encodeWithSignature( "joinswapPoolAmountOut(address,uint256,uint256)", @@ -121,26 +127,32 @@ contract AmmAdapterMock is ERC20 { } function getRemoveLiquidityCalldata( + address /* _setToken */, address _pool, - address[] calldata /* _components */, + address[] calldata _components, uint256[] calldata _minTokensOut, uint256 _liquidity ) external view returns (address _target, uint256 _value, bytes memory _calldata) { // Validate the pool and components are legit? - isValidPool(_pool); + isValidPool(_pool, _components); bytes memory callData = abi.encodeWithSignature("exitPool(uint256,uint256[])", _liquidity, _minTokensOut); return (address(this), 0, callData); } function getRemoveLiquiditySingleAssetCalldata( + address /* _setToken */, address _pool, address _component, uint256 _minTokenOut, uint256 _liquidity ) external view returns (address _target, uint256 _value, bytes memory _calldata) { + + address[] memory components = new address[](1); + components[0] = _component; + // Pool must be this address - isValidPool(_pool); + isValidPool(_pool, components); bytes memory callData = abi.encodeWithSignature( "exitswapPoolAmountIn(address,uint256,uint256)", @@ -151,7 +163,7 @@ contract AmmAdapterMock is ERC20 { return (address(this), 0, callData); } - function isValidPool(address _pool) public view returns(bool) { + function isValidPool(address _pool,address[] memory /*_components*/) public view returns(bool) { return _pool == address(this) || _pool == approvedToken; } diff --git a/contracts/protocol/integration/amm/UniswapV2AmmAdapter.sol b/contracts/protocol/integration/amm/UniswapV2AmmAdapter.sol new file mode 100644 index 000000000..c0cf8ab3c --- /dev/null +++ b/contracts/protocol/integration/amm/UniswapV2AmmAdapter.sol @@ -0,0 +1,309 @@ +/* + 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; + +import "../../../interfaces/external/IUniswapV2Router.sol"; +import "../../../interfaces/external/IUniswapV2Pair.sol"; +import "../../../interfaces/external/IUniswapV2Factory.sol"; +import "../../../interfaces/IAmmAdapter.sol"; +import "@openzeppelin/contracts/math/Math.sol"; +import "@openzeppelin/contracts/math/SafeMath.sol"; + +/** + * @title UniswapV2AmmAdapter + * @author Stephen Hankinson + * + * Adapter for Uniswap V2 Router that encodes adding and removing liquidty + */ +contract UniswapV2AmmAdapter is IAmmAdapter { + using SafeMath for uint256; + + /* ============ State Variables ============ */ + + // Address of Uniswap V2 Router contract + address public immutable router; + IUniswapV2Factory public immutable factory; + + // Internal function string for adding liquidity + string internal constant ADD_LIQUIDITY = + "addLiquidity(address,address,uint256,uint256,uint256,uint256,address,uint256)"; + // Internal function string for removing liquidity + string internal constant REMOVE_LIQUIDITY = + "removeLiquidity(address,address,uint256,uint256,uint256,address,uint256)"; + + /* ============ Constructor ============ */ + + /** + * Set state variables + * + * @param _router Address of Uniswap V2 Router contract + */ + constructor(address _router) public { + router = _router; + factory = IUniswapV2Factory(IUniswapV2Router(_router).factory()); + } + + /* ============ 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 setToken = _setToken; + address[] memory components = _components; + uint256[] memory maxTokensIn = _maxTokensIn; + uint256 minLiquidity = _minLiquidity; + IUniswapV2Pair pair = IUniswapV2Pair(_pool); + + require(maxTokensIn[0] > 0 && maxTokensIn[1] > 0, "Component quantity must be nonzero"); + + // We expect the totalSupply to be greater than 0 because the isValidPool would + // have passed by this point, meaning a pool for these tokens exist, which also + // means there is at least MINIMUM_LIQUIDITY liquidity tokens in the pool + // https://github.com/Uniswap/uniswap-v2-core/blob/master/contracts/UniswapV2Pair.sol#L121 + // If this is the case, we know the liquidity returned from the pool is equal to the minimum + // of the given supplied token multiplied by the totalSupply of liquidity tokens divided by + // the pool reserves of that token. + // https://github.com/Uniswap/uniswap-v2-core/blob/master/contracts/UniswapV2Pair.sol#L123 + + uint256 amountAMin; + uint256 amountBMin; + { // scope for reserveA, reserveB, totalSupply and liquidityExpectedFromSuppliedTokens, avoids stack too deep errors + uint256 totalSupply = pair.totalSupply(); + (uint256 reserveA, uint256 reserveB) = _getReserves(pair, components[0]); + + uint256 liquidityExpectedFromSuppliedTokens = Math.min( + maxTokensIn[0].mul(totalSupply).div(reserveA), + maxTokensIn[1].mul(totalSupply).div(reserveB) + ); + + require( + minLiquidity <= liquidityExpectedFromSuppliedTokens, + "_minLiquidity is too high for input token limit" + ); + + // Now that we know the minimum expected liquidity to receive for the amount of tokens + // that are being supplied, we can reverse the above equations in the min function to + // determine how much actual tokens are supplied to the pool, therefore setting our + // amountAMin and amountBMin of the addLiquidity call to the expected amounts. + + amountAMin = liquidityExpectedFromSuppliedTokens.mul(reserveA).div(totalSupply); + amountBMin = liquidityExpectedFromSuppliedTokens.mul(reserveB).div(totalSupply); + } + + target = router; + value = 0; + data = abi.encodeWithSignature( + ADD_LIQUIDITY, + components[0], + components[1], + maxTokensIn[0], + maxTokensIn[1], + amountAMin, + amountBMin, + setToken, + block.timestamp // solhint-disable-line not-rely-on-time + ); + } + + /** + * 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*/) + { + revert("Uniswap V2 single asset addition is not supported"); + } + + /** + * 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 setToken = _setToken; + address[] memory components = _components; + uint256[] memory minTokensOut = _minTokensOut; + uint256 liquidity = _liquidity; + IUniswapV2Pair pair = IUniswapV2Pair(_pool); + + // Make sure that only up the amount of liquidity tokens owned by the Set Token are redeemed + uint256 setTokenLiquidityBalance = pair.balanceOf(setToken); + require(_liquidity <= setTokenLiquidityBalance, "_liquidity must be <= to current balance"); + + { // scope for reserveA, reserveB, totalSupply, reservesOwnedByLiquidityA, and reservesOwnedByLiquidityB, avoids stack too deep errors + // For a given Uniswap V2 Liquidity Pool, an owner of a liquidity token is able to claim + // a portion of the reserves of that pool based on the percentage of liquidity tokens that + // they own in relation to the total supply of the liquidity tokens. So if a user owns 25% + // of the pool tokens, they would in effect own 25% of both reserveA and reserveB contained + // within the pool. Therefore, given the value of _liquidity we can calculate how much of the + // reserves the caller is requesting and can then validate that the _minTokensOut values are + // less than or equal to that amount. If not, they are requesting too much of the _components + // relative to the amount of liquidty that they are redeeming. + uint256 totalSupply = pair.totalSupply(); + (uint256 reserveA, uint256 reserveB) = _getReserves(pair, components[0]); + uint256 reservesOwnedByLiquidityA = reserveA.mul(liquidity).div(totalSupply); + uint256 reservesOwnedByLiquidityB = reserveB.mul(liquidity).div(totalSupply); + + require( + minTokensOut[0] <= reservesOwnedByLiquidityA && minTokensOut[1] <= reservesOwnedByLiquidityB, + "amounts must be <= ownedTokens" + ); + } + + target = router; + value = 0; + data = abi.encodeWithSignature( + REMOVE_LIQUIDITY, + components[0], + components[1], + liquidity, + minTokensOut[0], + minTokensOut[1], + setToken, + block.timestamp // solhint-disable-line not-rely-on-time + ); + } + + /** + * 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*/) + { + revert("Uniswap V2 single asset removal is not supported"); + } + + /** + * Returns the address of the spender + */ + function getSpenderAddress(address /*_pool*/) + external + view + override + returns (address spender) + { + spender = router; + } + + /** + * Verifies that this is a valid Uniswap V2 pool + * + * @param _pool Address of liquidity token + * @param _components Address array of supplied/requested tokens + */ + function isValidPool(address _pool, address[] memory _components) + external + view + override + returns (bool) { + // Attempt to get the factory of the provided pool + IUniswapV2Factory poolFactory; + try IUniswapV2Pair(_pool).factory() returns (address _factory) { + poolFactory = IUniswapV2Factory(_factory); + } catch { + return false; + } + + // Make sure the pool factory is the expected value, that we have the + // two required components, and that the pair address returned + // by the factory matches the supplied _pool value + if( + factory != poolFactory || + _components.length != 2 || + factory.getPair(_components[0], _components[1]) != _pool + ) { + return false; + } + + return true; + } + + /* ============ Internal Functions =================== */ + + /** + * Returns the pair reserves in an expected order + * + * @param pair The pair to get the reserves from + * @param tokenA Address of the token to swap + */ + function _getReserves( + IUniswapV2Pair pair, + address tokenA + ) + internal + view + returns (uint reserveA, uint reserveB) + { + address token0 = pair.token0(); + (uint reserve0, uint reserve1,) = pair.getReserves(); + (reserveA, reserveB) = tokenA == token0 ? (reserve0, reserve1) : (reserve1, reserve0); + } + +} \ No newline at end of file diff --git a/contracts/protocol/modules/AmmModule.sol b/contracts/protocol/modules/AmmModule.sol index 2d611bbd8..d8a943d65 100644 --- a/contracts/protocol/modules/AmmModule.sol +++ b/contracts/protocol/modules/AmmModule.sol @@ -128,8 +128,6 @@ contract AmmModule is ModuleBase, ReentrancyGuard { _validateAddLiquidity(actionInfo); - _executeApprovals(actionInfo); - _executeAddLiquidity(actionInfo); _validateMinimumLiquidityReceived(actionInfo); @@ -177,8 +175,6 @@ contract AmmModule is ModuleBase, ReentrancyGuard { _validateAddLiquidity(actionInfo); - _executeApprovals(actionInfo); - _executeAddLiquiditySingleAsset(actionInfo); _validateMinimumLiquidityReceived(actionInfo); @@ -413,12 +409,12 @@ contract AmmModule is ModuleBase, ReentrancyGuard { require(_actionInfo.liquidityQuantity > 0, "Token quantity must be nonzero"); require( - _actionInfo.ammAdapter.isValidPool(_actionInfo.liquidityToken), + _actionInfo.ammAdapter.isValidPool(_actionInfo.liquidityToken, _actionInfo.components), "Pool token must be enabled on the Adapter" ); } - function _executeApprovals(ActionInfo memory _actionInfo) internal { + function _executeComponentApprovals(ActionInfo memory _actionInfo) internal { address spender = _actionInfo.ammAdapter.getSpenderAddress(_actionInfo.liquidityToken); // Loop through and approve total notional tokens to spender @@ -435,12 +431,15 @@ contract AmmModule is ModuleBase, ReentrancyGuard { ( address targetAmm, uint256 callValue, bytes memory methodData ) = _actionInfo.ammAdapter.getProvideLiquidityCalldata( + address(_actionInfo.setToken), _actionInfo.liquidityToken, _actionInfo.components, _actionInfo.totalNotionalComponents, _actionInfo.liquidityQuantity ); + _executeComponentApprovals(_actionInfo); + _actionInfo.setToken.invoke(targetAmm, callValue, methodData); } @@ -448,12 +447,15 @@ contract AmmModule is ModuleBase, ReentrancyGuard { ( address targetAmm, uint256 callValue, bytes memory methodData ) = _actionInfo.ammAdapter.getProvideLiquiditySingleAssetCalldata( + address(_actionInfo.setToken), _actionInfo.liquidityToken, _actionInfo.components[0], _actionInfo.totalNotionalComponents[0], _actionInfo.liquidityQuantity ); + _executeComponentApprovals(_actionInfo); + _actionInfo.setToken.invoke(targetAmm, callValue, methodData); } @@ -461,12 +463,19 @@ contract AmmModule is ModuleBase, ReentrancyGuard { ( address targetAmm, uint256 callValue, bytes memory methodData ) = _actionInfo.ammAdapter.getRemoveLiquidityCalldata( + address(_actionInfo.setToken), _actionInfo.liquidityToken, _actionInfo.components, _actionInfo.totalNotionalComponents, _actionInfo.liquidityQuantity ); + _actionInfo.setToken.invokeApprove( + _actionInfo.liquidityToken, + _actionInfo.ammAdapter.getSpenderAddress(_actionInfo.liquidityToken), + _actionInfo.liquidityQuantity + ); + _actionInfo.setToken.invoke(targetAmm, callValue, methodData); } @@ -474,12 +483,19 @@ contract AmmModule is ModuleBase, ReentrancyGuard { ( address targetAmm, uint256 callValue, bytes memory methodData ) = _actionInfo.ammAdapter.getRemoveLiquiditySingleAssetCalldata( + address(_actionInfo.setToken), _actionInfo.liquidityToken, _actionInfo.components[0], _actionInfo.totalNotionalComponents[0], _actionInfo.liquidityQuantity ); + _actionInfo.setToken.invokeApprove( + _actionInfo.liquidityToken, + _actionInfo.ammAdapter.getSpenderAddress(_actionInfo.liquidityToken), + _actionInfo.liquidityQuantity + ); + _actionInfo.setToken.invoke(targetAmm, callValue, methodData); } diff --git a/package.json b/package.json index 55b8d2ddc..40d51d656 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "@types/lodash": "^4.14.86", "@types/mocha": "^7.0.2", "@types/node": "^14.0.5", + "@uniswap/lib": "^4.0.1-alpha", "chai": "^4.2.0", "coveralls": "^3.0.1", "dotenv": "^8.2.0", diff --git a/test/protocol/integration/amm/UniswapV2AmmAdapter.spec.ts b/test/protocol/integration/amm/UniswapV2AmmAdapter.spec.ts new file mode 100644 index 000000000..96f91c7a5 --- /dev/null +++ b/test/protocol/integration/amm/UniswapV2AmmAdapter.spec.ts @@ -0,0 +1,772 @@ +import "module-alias/register"; + +import { BigNumber } from "@ethersproject/bignumber"; + +import { Address } from "@utils/types"; +import { Account } from "@utils/test/types"; +import { + MAX_UINT_256, + ADDRESS_ZERO, + ZERO, +} from "@utils/constants"; +import { SetToken, AmmModule, UniswapV2AmmAdapter, UniswapV2Pair } from "@utils/contracts"; +import DeployHelper from "@utils/deploys"; +import { + ether +} from "@utils/index"; +import { + addSnapshotBeforeRestoreAfterEach, + getAccounts, + getSystemFixture, + getUniswapFixture, + getWaffleExpect, + getLastBlockTimestamp +} from "@utils/test/index"; + +import { SystemFixture, UniswapFixture } from "@utils/fixtures"; + +const expect = getWaffleExpect(); + +async function getReserves(pair: UniswapV2Pair, token: string): Promise<[BigNumber, BigNumber]> { + const token0 = await pair.token0(); + const [reserve0, reserve1, ] = await pair.getReserves(); + return token0 == token ? [reserve0, reserve1] : [reserve1, reserve0]; +} + +describe("UniswapV2AmmAdapter", () => { + let owner: Account; + let deployer: DeployHelper; + let setup: SystemFixture; + let uniswapSetup: UniswapFixture; + let ammModule: AmmModule; + + let uniswapV2AmmAdapter: UniswapV2AmmAdapter; + let uniswapV2AmmAdapterName: string; + + before(async () => { + [ + owner, + ] = await getAccounts(); + + deployer = new DeployHelper(owner.wallet); + setup = getSystemFixture(owner.address); + await setup.initialize(); + + uniswapSetup = getUniswapFixture(owner.address); + await uniswapSetup.initialize( + owner, + setup.weth.address, + setup.wbtc.address, + setup.dai.address + ); + await setup.weth.connect(owner.wallet) + .approve(uniswapSetup.router.address, MAX_UINT_256); + await setup.dai.connect(owner.wallet) + .approve(uniswapSetup.router.address, MAX_UINT_256); + await uniswapSetup.router.connect(owner.wallet).addLiquidity( + setup.weth.address, + setup.dai.address, + ether(200), + ether(600000), + ether(0), + ether(0), + owner.address, + MAX_UINT_256 + ); + + ammModule = await deployer.modules.deployAmmModule(setup.controller.address); + await setup.controller.addModule(ammModule.address); + + uniswapV2AmmAdapter = await deployer.adapters.deployUniswapV2AmmAdapter(uniswapSetup.router.address); + uniswapV2AmmAdapterName = "UNISWAPV2AMM"; + + await setup.integrationRegistry.addIntegration( + ammModule.address, + uniswapV2AmmAdapterName, + uniswapV2AmmAdapter.address + ); + }); + + addSnapshotBeforeRestoreAfterEach(); + + describe("constructor", async () => { + async function subject(): Promise { + return await uniswapV2AmmAdapter.router(); + } + + it("should have the correct router address", async () => { + const actualRouterAddress = await subject(); + expect(actualRouterAddress).to.eq(uniswapSetup.router.address); + }); + }); + + describe("getSpenderAddress", async () => { + let poolAddress: Address; + + before(async () => { + poolAddress = uniswapSetup.wethDaiPool.address; + }); + + async function subject(): Promise { + return await uniswapV2AmmAdapter.getSpenderAddress(poolAddress); + } + + it("should return the correct spender address", async () => { + const spender = await subject(); + expect(spender).to.eq(uniswapSetup.router.address); + }); + + }); + + describe("isValidPool", async () => { + let subjectAmmPool: Address; + let subjectComponents: Address[]; + + beforeEach(async () => { + subjectAmmPool = uniswapSetup.wethDaiPool.address; + subjectComponents = [setup.weth.address, setup.dai.address]; + }); + + async function subject(): Promise { + return await uniswapV2AmmAdapter.isValidPool(subjectAmmPool, subjectComponents); + } + + it("should be a valid pool", async () => { + const status = await subject(); + expect(status).to.be.true; + }); + + describe("when the pool address is invalid", async () => { + beforeEach(async () => { + subjectAmmPool = setup.weth.address; + }); + + it("should be an invalid pool", async () => { + const status = await subject(); + expect(status).to.be.false; + }); + }); + + describe("when the components don't match", async () => { + beforeEach(async () => { + subjectComponents = [setup.weth.address, setup.wbtc.address]; + }); + + it("should be an invalid pool", async () => { + const status = await subject(); + expect(status).to.be.false; + }); + }); + + describe("when the number of components is incorrect", async () => { + beforeEach(async () => { + subjectComponents = [setup.weth.address]; + }); + + it("should be an invalid pool", async () => { + const status = await subject(); + expect(status).to.be.false; + }); + }); + + describe("when the router doesn't match", async () => { + beforeEach(async () => { + const otherUniswapSetup = getUniswapFixture(owner.address); + await otherUniswapSetup.initialize(owner, setup.weth.address, setup.wbtc.address, setup.dai.address); + subjectAmmPool = otherUniswapSetup.wethDaiPool.address; + }); + + it("should be an invalid pool", async () => { + const status = await subject(); + expect(status).to.be.false; + }); + }); + + }); + + describe("getProvideLiquiditySingleAssetCalldata", async () => { + let subjectAmmPool: Address; + let subjectComponent: Address; + let subjectMaxTokenIn: BigNumber; + let subjectMinLiquidity: BigNumber; + + before(async () => { + subjectAmmPool = uniswapSetup.wethDaiPool.address; + subjectComponent = setup.weth.address; + subjectMaxTokenIn = ether(1); + subjectMinLiquidity = ether(1); + }); + + async function subject(): Promise { + return await uniswapV2AmmAdapter.getProvideLiquiditySingleAssetCalldata( + uniswapSetup.router.address, + subjectAmmPool, + subjectComponent, + subjectMaxTokenIn, + subjectMinLiquidity); + } + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Uniswap V2 single asset addition is not supported"); + }); + }); + + describe("getRemoveLiquiditySingleAssetCalldata", async () => { + let subjectAmmPool: Address; + let subjectComponent: Address; + let subjectMinTokenOut: BigNumber; + let subjectLiquidity: BigNumber; + + before(async () => { + subjectAmmPool = uniswapSetup.wethDaiPool.address; + subjectComponent = setup.weth.address; + subjectMinTokenOut = ether(1); + subjectLiquidity = ether(1); + }); + + async function subject(): Promise { + return await uniswapV2AmmAdapter.getRemoveLiquiditySingleAssetCalldata( + owner.address, + subjectAmmPool, + subjectComponent, + subjectMinTokenOut, + subjectLiquidity); + } + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Uniswap V2 single asset removal is not supported"); + }); + }); + + describe("getProvideLiquidityCalldata", async () => { + let subjectAmmPool: Address; + let subjectComponents: Address[]; + let subjectMaxTokensIn: BigNumber[]; + let subjectMinLiquidity: BigNumber; + + beforeEach(async () => { + subjectAmmPool = uniswapSetup.wethDaiPool.address; + subjectComponents = [setup.weth.address, setup.dai.address]; + subjectMaxTokensIn = [ether(1), ether(3000)]; + const totalSupply = await uniswapSetup.wethDaiPool.totalSupply(); + const [reserveA, reserveB] = await getReserves(uniswapSetup.wethDaiPool, setup.weth.address); + const liquidityA = subjectMaxTokensIn[0].mul(totalSupply).div(reserveA); + const liquidityB = subjectMaxTokensIn[1].mul(totalSupply).div(reserveB); + subjectMinLiquidity = liquidityA.lt(liquidityB) ? liquidityA : liquidityB; + }); + + async function subject(): Promise { + return await uniswapV2AmmAdapter.getProvideLiquidityCalldata( + owner.address, + subjectAmmPool, + subjectComponents, + subjectMaxTokensIn, + subjectMinLiquidity); + } + + it("should return the correct provide liquidity calldata", async () => { + const calldata = await subject(); + const blockTimestamp = await getLastBlockTimestamp(); + + // Determine how much of each token the _minLiquidity would return + const totalSupply = await uniswapSetup.wethDaiPool.totalSupply(); + const [reserveA, reserveB] = await getReserves(uniswapSetup.wethDaiPool, setup.weth.address); + const amountAMin = reserveA.mul(subjectMinLiquidity).div(totalSupply); + const amountBMin = reserveB.mul(subjectMinLiquidity).div(totalSupply); + + const expectedCallData = uniswapSetup.router.interface.encodeFunctionData("addLiquidity", [ + setup.weth.address, + setup.dai.address, + subjectMaxTokensIn[0], + subjectMaxTokensIn[1], + amountAMin, + amountBMin, + owner.address, + blockTimestamp, + ]); + expect(JSON.stringify(calldata)).to.eq(JSON.stringify([uniswapSetup.router.address, ZERO, expectedCallData])); + }); + + describe("when the _pool totalSupply is 0", async () => { + beforeEach(async () => { + subjectAmmPool = uniswapSetup.wethWbtcPool.address; + subjectComponents = [setup.weth.address, setup.wbtc.address]; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("SafeMath: division by zero"); + }); + }); + + describe("when the _minLiquidity is too high", async () => { + beforeEach(async () => { + subjectMinLiquidity = subjectMinLiquidity.mul(2); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("_minLiquidity is too high for input token limit"); + }); + }); + }); + + describe("getRemoveLiquidityCalldata", async () => { + let subjectAmmPool: Address; + let subjectComponents: Address[]; + let subjectMinTokensOut: BigNumber[]; + let subjectLiquidity: BigNumber; + + beforeEach(async () => { + subjectAmmPool = uniswapSetup.wethDaiPool.address; + subjectComponents = [setup.weth.address, setup.dai.address]; + subjectLiquidity = ether(1); + + // Determine how much of each token the subjectLiquidity should return + const totalSupply = await uniswapSetup.wethDaiPool.totalSupply(); + const [reserveA, reserveB] = await getReserves(uniswapSetup.wethDaiPool, setup.weth.address); + const amountAMin = reserveA.mul(subjectLiquidity).div(totalSupply); + const amountBMin = reserveB.mul(subjectLiquidity).div(totalSupply); + + subjectMinTokensOut = [amountAMin, amountBMin]; + }); + + async function subject(): Promise { + return await uniswapV2AmmAdapter.getRemoveLiquidityCalldata( + owner.address, + subjectAmmPool, + subjectComponents, + subjectMinTokensOut, + subjectLiquidity); + } + + it("should return the correct remove liquidity calldata", async () => { + const calldata = await subject(); + const blockTimestamp = await getLastBlockTimestamp(); + + const expectedCallData = uniswapSetup.router.interface.encodeFunctionData("removeLiquidity", [ + setup.weth.address, + setup.dai.address, + subjectLiquidity, + subjectMinTokensOut[0], + subjectMinTokensOut[1], + owner.address, + blockTimestamp, + ]); + expect(JSON.stringify(calldata)).to.eq(JSON.stringify([uniswapSetup.router.address, ZERO, expectedCallData])); + }); + + describe("when the _liquidity is more than available", async () => { + beforeEach(async () => { + subjectLiquidity = (await uniswapSetup.wethDaiPool.balanceOf(owner.address)).add(ether(1)); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("_liquidity must be <= to current balance"); + }); + }); + + describe("when the _minTokensOut is too high", async () => { + beforeEach(async () => { + const balance = await uniswapSetup.wethDaiPool.balanceOf(owner.address); + const totalSupply = await uniswapSetup.wethDaiPool.totalSupply(); + const [reserveA, ] = await getReserves(uniswapSetup.wethDaiPool, setup.weth.address); + const tooMuchEth = balance.mul(reserveA).div(totalSupply).add(ether(1)); + subjectMinTokensOut = [tooMuchEth, subjectMinTokensOut[1]]; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("amounts must be <= ownedTokens"); + }); + }); + }); + + context("Add and Remove Liquidity Tests", async () => { + let subjectCaller: Account; + let subjectSetToken: Address; + let subjectIntegrationName: string; + let subjectAmmPool: Address; + + let setToken: SetToken; + + context("when there is a deployed SetToken with enabled AmmModule", async () => { + beforeEach(async () => { + // Deploy a standard SetToken with the AMM Module + setToken = await setup.createSetToken( + [setup.weth.address, setup.dai.address], + [ether(1), ether(3000)], + [setup.issuanceModule.address, ammModule.address] + ); + + await setup.issuanceModule.initialize(setToken.address, ADDRESS_ZERO); + await ammModule.initialize(setToken.address); + + // Mint some instances of the SetToken + await setup.approveAndIssueSetToken(setToken, ether(1)); + }); + + describe("#addLiquidity", async () => { + let subjectComponentsToInput: Address[]; + let subjectMaxComponentQuantities: BigNumber[]; + let subjectMinPoolTokensToMint: BigNumber; + + beforeEach(async () => { + subjectSetToken = setToken.address; + subjectIntegrationName = uniswapV2AmmAdapterName; + subjectAmmPool = uniswapSetup.wethDaiPool.address; + subjectComponentsToInput = [setup.weth.address, setup.dai.address]; + subjectMaxComponentQuantities = [ether(1), ether(3000)]; + subjectCaller = owner; + const totalSupply = await uniswapSetup.wethDaiPool.totalSupply(); + const [reserveA, reserveB] = await getReserves(uniswapSetup.wethDaiPool, subjectComponentsToInput[0]); + const liquidityA = subjectMaxComponentQuantities[0].mul(totalSupply).div(reserveA); + const liquidityB = subjectMaxComponentQuantities[1].mul(totalSupply).div(reserveB); + subjectMinPoolTokensToMint = liquidityA.lt(liquidityB) ? liquidityA : liquidityB; + }); + + async function subject(): Promise { + return await ammModule.connect(subjectCaller.wallet).addLiquidity( + subjectSetToken, + subjectIntegrationName, + subjectAmmPool, + subjectMinPoolTokensToMint, + subjectComponentsToInput, + subjectMaxComponentQuantities, + ); + } + + it("should mint the liquidity token to the caller", async () => { + await subject(); + const liquidityTokenBalance = await uniswapSetup.wethDaiPool.balanceOf(subjectSetToken); + expect(liquidityTokenBalance).to.eq(subjectMinPoolTokensToMint); + }); + + it("should update the positions properly", async () => { + await subject(); + const positions = await setToken.getPositions(); + expect(positions.length).to.eq(1); + expect(positions[0].component).to.eq(subjectAmmPool); + expect(positions[0].unit).to.eq(subjectMinPoolTokensToMint); + }); + + describe("when insufficient liquidity tokens are received", async () => { + beforeEach(async () => { + subjectMinPoolTokensToMint = subjectMinPoolTokensToMint.mul(2); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("_minLiquidity is too high for input token limit"); + }); + }); + + describe("when extra dai tokens are supplied", async () => { + let wethRemaining: BigNumber; + let daiRemaining: BigNumber; + beforeEach(async () => { + wethRemaining = ether(0.5); + subjectMaxComponentQuantities = [ether(0.5), ether(1600)]; + const totalSupply = await uniswapSetup.wethDaiPool.totalSupply(); + const [reserveA, reserveB] = await getReserves(uniswapSetup.wethDaiPool, subjectComponentsToInput[0]); + const liquidityA = ether(0.5).mul(totalSupply).div(reserveA); + const liquidityB = ether(1600).mul(totalSupply).div(reserveB); + subjectMinPoolTokensToMint = liquidityA.lt(liquidityB) ? liquidityA : liquidityB; + daiRemaining = ether(3000).sub(ether(0.5).mul(reserveB).div(reserveA)); + }); + + it("should mint the correct amount of liquidity tokens to the caller", async () => { + await subject(); + const liquidityTokenBalance = await uniswapSetup.wethDaiPool.balanceOf(subjectSetToken); + expect(liquidityTokenBalance).to.eq(subjectMinPoolTokensToMint); + }); + + it("should have the expected weth, dai, and lp tokens", async () => { + await subject(); + const positions = await setToken.getPositions(); + expect(positions.length).to.eq(3); + expect(positions[0].component).to.eq(setup.weth.address); + expect(positions[0].unit).to.eq(wethRemaining); + expect(positions[1].component).to.eq(setup.dai.address); + expect(positions[1].unit).to.eq(daiRemaining); + expect(positions[2].component).to.eq(subjectAmmPool); + expect(positions[2].unit).to.eq(subjectMinPoolTokensToMint); + }); + }); + + describe("when extra weth tokens are supplied", async () => { + let wethRemaining: BigNumber; + let daiRemaining: BigNumber; + beforeEach(async () => { + daiRemaining = ether(1500); + subjectMaxComponentQuantities = [ether(0.6), ether(1500)]; + const totalSupply = await uniswapSetup.wethDaiPool.totalSupply(); + const [reserveA, reserveB] = await getReserves(uniswapSetup.wethDaiPool, subjectComponentsToInput[0]); + const liquidityA = ether(0.6).mul(totalSupply).div(reserveA); + const liquidityB = ether(1500).mul(totalSupply).div(reserveB); + subjectMinPoolTokensToMint = liquidityA.lt(liquidityB) ? liquidityA : liquidityB; + wethRemaining = ether(1).sub(ether(1500).mul(reserveA).div(reserveB)); + }); + + it("should mint the correct amount of liquidity tokens to the caller", async () => { + await subject(); + const liquidityTokenBalance = await uniswapSetup.wethDaiPool.balanceOf(subjectSetToken); + expect(liquidityTokenBalance).to.eq(subjectMinPoolTokensToMint); + }); + + it("should have the expected weth, dai, and lp tokens", async () => { + await subject(); + const positions = await setToken.getPositions(); + expect(positions.length).to.eq(3); + expect(positions[0].component).to.eq(setup.weth.address); + expect(positions[0].unit).to.eq(wethRemaining); + expect(positions[1].component).to.eq(setup.dai.address); + expect(positions[1].unit).to.eq(daiRemaining); + expect(positions[2].component).to.eq(subjectAmmPool); + expect(positions[2].unit).to.eq(subjectMinPoolTokensToMint); + }); + }); + + describe("when the pool address is invalid", async () => { + beforeEach(async () => { + const otherUniswapSetup = getUniswapFixture(owner.address); + await otherUniswapSetup.initialize(owner, setup.weth.address, setup.wbtc.address, setup.dai.address); + subjectAmmPool = otherUniswapSetup.wethDaiPool.address; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Pool token must be enabled on the Adapter"); + }); + }); + + describe("when the _components length is invalid", async () => { + beforeEach(async () => { + subjectComponentsToInput = [setup.weth.address]; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Components and units must be equal length"); + }); + }); + + describe("when the _maxTokensIn length is invalid", async () => { + beforeEach(async () => { + subjectMaxComponentQuantities = [ether(1)]; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Components and units must be equal length"); + }); + }); + + describe("when the _pool doesn't match the _components", async () => { + beforeEach(async () => { + subjectComponentsToInput = [setup.weth.address, setup.wbtc.address]; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Pool token must be enabled on the Adapter"); + }); + }); + + describe("when the _maxTokensIn[0] is 0", async () => { + beforeEach(async () => { + subjectMaxComponentQuantities = [ether(0), ether(3000)]; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Component quantity must be nonzero"); + }); + }); + + describe("when the _maxTokensIn[1] is 0", async () => { + beforeEach(async () => { + subjectMaxComponentQuantities = [ether(1), ether(0)]; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Component quantity must be nonzero"); + }); + }); + + describe("when the _minLiquidity is 0", async () => { + beforeEach(async () => { + subjectMinPoolTokensToMint = ZERO; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Token quantity must be nonzero"); + }); + }); + + shouldRevertIfPoolIsNotSupported(subject); + }); + + }); + + context("when there is a deployed SetToken with enabled AmmModule", async () => { + before(async () => { + // Deploy a standard SetToken with the AMM Module + setToken = await setup.createSetToken( + [uniswapSetup.wethDaiPool.address], + [ether(1)], + [setup.issuanceModule.address, ammModule.address] + ); + + await setup.issuanceModule.initialize(setToken.address, ADDRESS_ZERO); + await ammModule.initialize(setToken.address); + + // Mint some instances of the SetToken + await setup.approveAndIssueSetToken(setToken, ether(1)); + }); + + describe("#removeLiquidity", async () => { + let subjectComponentsToOutput: Address[]; + let subjectMinComponentQuantities: BigNumber[]; + let subjectPoolTokens: BigNumber; + + beforeEach(async () => { + subjectSetToken = setToken.address; + subjectIntegrationName = uniswapV2AmmAdapterName; + subjectAmmPool = uniswapSetup.wethDaiPool.address; + subjectComponentsToOutput = [setup.weth.address, setup.dai.address]; + const totalSupply = await uniswapSetup.wethDaiPool.totalSupply(); + const [reserveA, reserveB] = await getReserves(uniswapSetup.wethDaiPool, subjectComponentsToOutput[0]); + subjectPoolTokens = ether(1); + const weth = subjectPoolTokens.mul(reserveA).div(totalSupply); + const dai = subjectPoolTokens.mul(reserveB).div(totalSupply); + subjectMinComponentQuantities = [weth, dai]; + subjectCaller = owner; + }); + + async function subject(): Promise { + return await ammModule.connect(subjectCaller.wallet).removeLiquidity( + subjectSetToken, + subjectIntegrationName, + subjectAmmPool, + subjectPoolTokens, + subjectComponentsToOutput, + subjectMinComponentQuantities, + ); + } + + it("should reduce the liquidity token of the caller", async () => { + const previousLiquidityTokenBalance = await uniswapSetup.wethDaiPool.balanceOf(subjectSetToken); + + await subject(); + const liquidityTokenBalance = await uniswapSetup.wethDaiPool.balanceOf(subjectSetToken); + const expectedLiquidityBalance = previousLiquidityTokenBalance.sub(subjectPoolTokens); + expect(liquidityTokenBalance).to.eq(expectedLiquidityBalance); + }); + + it("should update the positions properly", async () => { + await subject(); + const positions = await setToken.getPositions(); + + expect(positions.length).to.eq(2); + + expect(positions[0].component).to.eq(setup.weth.address); + expect(positions[0].unit).to.eq(subjectMinComponentQuantities[0]); + expect(positions[1].component).to.eq(setup.dai.address); + expect(positions[1].unit).to.eq(subjectMinComponentQuantities[1]); + }); + + describe("when more underlying tokens are requested than owned", async () => { + beforeEach(async () => { + subjectMinComponentQuantities = [subjectMinComponentQuantities[0].mul(2), + subjectMinComponentQuantities[1]]; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("amounts must be <= ownedTokens"); + }); + }); + + describe("when the pool address is invalid", async () => { + beforeEach(async () => { + const otherUniswapSetup = getUniswapFixture(owner.address); + await otherUniswapSetup.initialize(owner, setup.weth.address, setup.wbtc.address, setup.dai.address); + subjectAmmPool = otherUniswapSetup.wethDaiPool.address; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Pool token must be enabled on the Adapter"); + }); + }); + + describe("when the _components length is invalid", async () => { + beforeEach(async () => { + subjectComponentsToOutput = [setup.weth.address]; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Components and units must be equal length"); + }); + }); + + describe("when the _minTokensOut length is invalid", async () => { + beforeEach(async () => { + subjectMinComponentQuantities = [ether(1)]; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Components and units must be equal length"); + }); + }); + + describe("when the _pool doesn't match the _components", async () => { + beforeEach(async () => { + subjectComponentsToOutput = [setup.weth.address, setup.wbtc.address]; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Pool token must be enabled on the Adapter"); + }); + }); + + describe("when the _minTokensOut[0] is 0", async () => { + beforeEach(async () => { + subjectMinComponentQuantities = [ether(0), ether(3000)]; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Component quantity must be nonzero"); + }); + }); + + describe("when the _minTokensOut[1] is 0", async () => { + beforeEach(async () => { + subjectMinComponentQuantities = [ether(1), ether(0)]; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Component quantity must be nonzero"); + }); + }); + + describe("when the _liquidity is 0", async () => { + beforeEach(async () => { + subjectPoolTokens = ZERO; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Token quantity must be nonzero"); + }); + }); + + shouldRevertIfPoolIsNotSupported(subject); + }); + + }); + + function shouldRevertIfPoolIsNotSupported(subject: any) { + describe("when the pool is not supported on the adapter", async () => { + beforeEach(async () => { + subjectAmmPool = setup.wbtc.address; + }); + + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Pool token must be enabled on the Adapter"); + }); + }); + } + }); + +}); diff --git a/utils/contracts/index.ts b/utils/contracts/index.ts index 0162b8c1d..4ab159359 100644 --- a/utils/contracts/index.ts +++ b/utils/contracts/index.ts @@ -92,6 +92,7 @@ export { TradeAdapterMock } from "../../typechain/TradeAdapterMock"; export { Uint256ArrayUtilsMock } from "../../typechain/Uint256ArrayUtilsMock"; export { Uni } from "../../typechain/Uni"; export { UniswapPairPriceAdapter } from "../../typechain/UniswapPairPriceAdapter"; +export { UniswapV2AmmAdapter } from "../../typechain/UniswapV2AmmAdapter"; 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 0ad70b64e..5629223c2 100644 --- a/utils/deploys/deployAdapters.ts +++ b/utils/deploys/deployAdapters.ts @@ -20,6 +20,7 @@ import { YearnWrapAdapter, YearnWrapV2Adapter, UniswapPairPriceAdapter, + UniswapV2AmmAdapter, UniswapV2ExchangeAdapter, UniswapV2ExchangeAdapterV2, UniswapV2IndexExchangeAdapter, @@ -55,6 +56,7 @@ import { YearnWrapAdapter__factory } from "../../typechain/factories/YearnWrapAd import { YearnWrapV2Adapter__factory } from "../../typechain/factories/YearnWrapV2Adapter__factory"; 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 { 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,10 @@ export default class DeployAdapters { ); } + public async deployUniswapV2AmmAdapter(uniswapV2Router: Address): Promise { + return await new UniswapV2AmmAdapter__factory(this._deployerSigner).deploy(uniswapV2Router); + } + public async deployUniswapV2ExchangeAdapter(uniswapV2Router: Address): Promise { return await new UniswapV2ExchangeAdapter__factory(this._deployerSigner).deploy(uniswapV2Router); }