From 1df8c5b48424c81b1de5d622d3d7ede1de41cdd6 Mon Sep 17 00:00:00 2001 From: Richard Liang Date: Tue, 13 Apr 2021 22:26:19 -0700 Subject: [PATCH] Add new Uniswap transfer fee adapter --- .../UniswapV2TransferFeeExchangeAdapter.sol | 107 +++++++++++ ...niswapV2TransferFeeExchangeAdapter.spec.ts | 167 +++++++++++++++++ test/protocol/modules/tradeModule.spec.ts | 172 +++++++++++++++++- utils/contracts/index.ts | 1 + utils/deploys/deployAdapters.ts | 6 + 5 files changed, 451 insertions(+), 2 deletions(-) create mode 100644 contracts/protocol/integration/UniswapV2TransferFeeExchangeAdapter.sol create mode 100644 test/protocol/integration/uniswapV2TransferFeeExchangeAdapter.spec.ts diff --git a/contracts/protocol/integration/UniswapV2TransferFeeExchangeAdapter.sol b/contracts/protocol/integration/UniswapV2TransferFeeExchangeAdapter.sol new file mode 100644 index 000000000..26872513c --- /dev/null +++ b/contracts/protocol/integration/UniswapV2TransferFeeExchangeAdapter.sol @@ -0,0 +1,107 @@ +/* + 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"; + +/** + * @title UniswapV2TransferFeeExchangeAdapter + * @author Set Protocol + * + * Exchange adapter for Uniswap V2 Router02 that supports trading tokens with transfer fees + */ +contract UniswapV2TransferFeeExchangeAdapter { + + /* ============ State Variables ============ */ + + // Address of Uniswap V2 Router02 contract + address public immutable router; + + /* ============ Constructor ============ */ + + /** + * Set state variables + * + * @param _router Address of Uniswap V2 Router02 contract + */ + constructor(address _router) public { + router = _router; + } + + /* ============ External Getter Functions ============ */ + + /** + * Return calldata for Uniswap V2 Router02 when trading a token with a transfer fee + * + * @param _sourceToken Address of source token to be sold + * @param _destinationToken Address of destination token to buy + * @param _destinationAddress Address that assets should be transferred to + * @param _sourceQuantity Amount of source token to sell + * @param _minDestinationQuantity Min amount of destination token to buy + * @param _data Arbitrary bytes containing trade call data + * + * @return address Target contract address + * @return uint256 Call value + * @return bytes Trade calldata + */ + function getTradeCalldata( + address _sourceToken, + address _destinationToken, + address _destinationAddress, + uint256 _sourceQuantity, + uint256 _minDestinationQuantity, + bytes memory _data + ) + external + view + returns (address, uint256, bytes memory) + { + address[] memory path; + + if(_data.length == 0){ + path = new address[](2); + path[0] = _sourceToken; + path[1] = _destinationToken; + } else { + path = abi.decode(_data, (address[])); + } + + bytes memory callData = abi.encodeWithSignature( + "swapExactTokensForTokensSupportingFeeOnTransferTokens(uint256,uint256,address[],address,uint256)", + _sourceQuantity, + _minDestinationQuantity, + path, + _destinationAddress, + block.timestamp + ); + return (router, 0, callData); + } + + /** + * Returns the address to approve source tokens to for trading. This is the Uniswap router address + * + * @return address Address of the contract to approve tokens to + */ + function getSpender() + external + view + returns (address) + { + return router; + } +} \ No newline at end of file diff --git a/test/protocol/integration/uniswapV2TransferFeeExchangeAdapter.spec.ts b/test/protocol/integration/uniswapV2TransferFeeExchangeAdapter.spec.ts new file mode 100644 index 000000000..f15333cba --- /dev/null +++ b/test/protocol/integration/uniswapV2TransferFeeExchangeAdapter.spec.ts @@ -0,0 +1,167 @@ +import "module-alias/register"; + +import { BigNumber } from "@ethersproject/bignumber"; +import { defaultAbiCoder } from "ethers/lib/utils"; + +import { Address, Bytes } from "@utils/types"; +import { Account } from "@utils/test/types"; +import { + EMPTY_BYTES, + ZERO, +} from "@utils/constants"; +import { UniswapV2TransferFeeExchangeAdapter } 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(); + +describe("UniswapV2TransferFeeExchangeAdapter", () => { + let owner: Account; + let mockSetToken: Account; + let deployer: DeployHelper; + let setup: SystemFixture; + let uniswapSetup: UniswapFixture; + + let uniswapV2TransferFeeExchangeAdapter: UniswapV2TransferFeeExchangeAdapter; + + before(async () => { + [ + owner, + mockSetToken, + ] = 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 + ); + + uniswapV2TransferFeeExchangeAdapter = await deployer.adapters.deployUniswapV2TransferFeeExchangeAdapter(uniswapSetup.router.address); + }); + + addSnapshotBeforeRestoreAfterEach(); + + describe("constructor", async () => { + let subjectUniswapRouter: Address; + + beforeEach(async () => { + subjectUniswapRouter = uniswapSetup.router.address; + }); + + async function subject(): Promise { + return await deployer.adapters.deployUniswapV2TransferFeeExchangeAdapter(subjectUniswapRouter); + } + + it("should have the correct router address", async () => { + const deployedUniswapV2TransferFeeExchangeAdapter = await subject(); + + const actualRouterAddress = await deployedUniswapV2TransferFeeExchangeAdapter.router(); + expect(actualRouterAddress).to.eq(uniswapSetup.router.address); + }); + }); + + describe("getSpender", async () => { + async function subject(): Promise { + return await uniswapV2TransferFeeExchangeAdapter.getSpender(); + } + + it("should return the correct spender address", async () => { + const spender = await subject(); + + expect(spender).to.eq(uniswapSetup.router.address); + }); + }); + + describe("getTradeCalldata", async () => { + let sourceAddress: Address; + let destinationAddress: Address; + let sourceQuantity: BigNumber; + let destinationQuantity: BigNumber; + + let subjectMockSetToken: Address; + let subjectSourceToken: Address; + let subjectDestinationToken: Address; + let subjectSourceQuantity: BigNumber; + let subjectMinDestinationQuantity: BigNumber; + let subjectData: Bytes; + + beforeEach(async () => { + sourceAddress = setup.wbtc.address; // WBTC Address + sourceQuantity = BigNumber.from(100000000); // Trade 1 WBTC + destinationAddress = setup.dai.address; // DAI Address + destinationQuantity = ether(30000); // Receive at least 30k DAI + + subjectSourceToken = sourceAddress; + subjectDestinationToken = destinationAddress; + subjectMockSetToken = mockSetToken.address; + subjectSourceQuantity = sourceQuantity; + subjectMinDestinationQuantity = destinationQuantity; + subjectData = EMPTY_BYTES; + }); + + async function subject(): Promise { + return await uniswapV2TransferFeeExchangeAdapter.getTradeCalldata( + subjectSourceToken, + subjectDestinationToken, + subjectMockSetToken, + subjectSourceQuantity, + subjectMinDestinationQuantity, + subjectData, + ); + } + + it("should return the correct trade calldata", async () => { + const calldata = await subject(); + const callTimestamp = await getLastBlockTimestamp(); + const expectedCallData = uniswapSetup.router.interface.encodeFunctionData("swapExactTokensForTokensSupportingFeeOnTransferTokens", [ + sourceQuantity, + destinationQuantity, + [sourceAddress, destinationAddress], + subjectMockSetToken, + callTimestamp, + ]); + expect(JSON.stringify(calldata)).to.eq(JSON.stringify([uniswapSetup.router.address, ZERO, expectedCallData])); + }); + + describe("when passed in custom path to trade data", async () => { + beforeEach(async () => { + const path = [sourceAddress, setup.weth.address, destinationAddress]; + subjectData = defaultAbiCoder.encode( + ["address[]"], + [path] + ); + }); + + it("should return the correct trade calldata", async () => { + const calldata = await subject(); + const callTimestamp = await getLastBlockTimestamp(); + const expectedCallData = uniswapSetup.router.interface.encodeFunctionData("swapExactTokensForTokensSupportingFeeOnTransferTokens", [ + sourceQuantity, + destinationQuantity, + [sourceAddress, setup.weth.address, destinationAddress], + subjectMockSetToken, + callTimestamp, + ]); + expect(JSON.stringify(calldata)).to.eq(JSON.stringify([uniswapSetup.router.address, ZERO, expectedCallData])); + }); + }); + }); +}); diff --git a/test/protocol/modules/tradeModule.spec.ts b/test/protocol/modules/tradeModule.spec.ts index c25d31d57..a544b4fea 100644 --- a/test/protocol/modules/tradeModule.spec.ts +++ b/test/protocol/modules/tradeModule.spec.ts @@ -14,8 +14,10 @@ import { OneInchExchangeMock, SetToken, StandardTokenMock, + StandardTokenWithFeeMock, TradeModule, UniswapV2ExchangeAdapter, + UniswapV2TransferFeeExchangeAdapter, UniswapV2ExchangeAdapterV2, WETH9, ZeroExApiAdapter, @@ -58,6 +60,8 @@ describe("TradeModule", () => { let uniswapExchangeAdapter: UniswapV2ExchangeAdapter; let uniswapAdapterName: string; + let uniswapTransferFeeExchangeAdapter: UniswapV2TransferFeeExchangeAdapter; + let uniswapTransferFeeAdapterName: string; let uniswapExchangeAdapterV2: UniswapV2ExchangeAdapterV2; let uniswapAdapterV2Name: string; @@ -119,6 +123,7 @@ describe("TradeModule", () => { ); uniswapExchangeAdapter = await deployer.adapters.deployUniswapV2ExchangeAdapter(uniswapSetup.router.address); + uniswapTransferFeeExchangeAdapter = await deployer.adapters.deployUniswapV2TransferFeeExchangeAdapter(uniswapSetup.router.address); uniswapExchangeAdapterV2 = await deployer.adapters.deployUniswapV2ExchangeAdapterV2(uniswapSetup.router.address); zeroExMock = await deployer.mocks.deployZeroExMock( @@ -133,6 +138,7 @@ describe("TradeModule", () => { kyberAdapterName = "KYBER"; oneInchAdapterName = "ONEINCH"; uniswapAdapterName = "UNISWAP"; + uniswapTransferFeeAdapterName = "UNISWAP_TRANSFER_FEE"; uniswapAdapterV2Name = "UNISWAPV2"; zeroExApiAdapterName = "ZERO_EX"; @@ -140,12 +146,13 @@ describe("TradeModule", () => { await setup.controller.addModule(tradeModule.address); await setup.integrationRegistry.batchAddIntegration( - [tradeModule.address, tradeModule.address, tradeModule.address, tradeModule.address, tradeModule.address], - [kyberAdapterName, oneInchAdapterName, uniswapAdapterName, uniswapAdapterV2Name, zeroExApiAdapterName], + [tradeModule.address, tradeModule.address, tradeModule.address, tradeModule.address, tradeModule.address, tradeModule.address], + [kyberAdapterName, oneInchAdapterName, uniswapAdapterName, uniswapTransferFeeAdapterName, uniswapAdapterV2Name, zeroExApiAdapterName], [ kyberExchangeAdapter.address, oneInchExchangeAdapter.address, uniswapExchangeAdapter.address, + uniswapTransferFeeExchangeAdapter.address, uniswapExchangeAdapterV2.address, zeroExApiAdapter.address, ] @@ -248,6 +255,7 @@ describe("TradeModule", () => { }); describe("#trade", async () => { + let tokenWithFee: StandardTokenWithFeeMock; let sourceTokenQuantity: BigNumber; let destinationTokenQuantity: BigNumber; let isInitialized: boolean; @@ -800,6 +808,166 @@ describe("TradeModule", () => { }); }); + context("when trading a Default component with a transfer fee on Uniswap", async () => { + cacheBeforeEach(async () => { + tokenWithFee = await deployer.mocks.deployTokenWithFeeMock(owner.address, ether(10000), ether(0.01)); + await tokenWithFee.connect(owner.wallet).approve(uniswapSetup.router.address, ether(10000)); + await setup.wbtc.connect(owner.wallet).approve(uniswapSetup.router.address, bitcoin(100)); + const poolPair = await uniswapSetup.createNewPair(tokenWithFee.address, setup.wbtc.address); + await uniswapSetup.router.addLiquidity( + tokenWithFee.address, + setup.wbtc.address, + ether(3400), + bitcoin(100), + ether(3000), + bitcoin(99.5), + owner.address, + MAX_UINT_256 + ); + await tokenWithFee.transfer(poolPair.address, ether(0.01)); + + tradeModule = tradeModule.connect(manager.wallet); + await tradeModule.initialize(setToken.address); + + const wbtcSendQuantity = wbtcUnits; + const destinationTokenDecimals = await setup.wbtc.decimals(); + sourceTokenQuantity = wbtcRate.mul(wbtcSendQuantity).div(10 ** destinationTokenDecimals); + + // Transfer sourceToken from owner to manager for issuance + await setup.wbtc.connect(owner.wallet).transfer(manager.address, wbtcUnits.mul(100)); + + // Approve tokens to Controller and call issue + await setup.wbtc.connect(manager.wallet).approve(setup.issuanceModule.address, ethers.constants.MaxUint256); + + // Deploy mock issuance hook and initialize issuance module + setup.issuanceModule = setup.issuanceModule.connect(manager.wallet); + mockPreIssuanceHook = await deployer.mocks.deployManagerIssuanceHookMock(); + await setup.issuanceModule.initialize(setToken.address, mockPreIssuanceHook.address); + + issueQuantity = ether(1); + await setup.issuanceModule.issue(setToken.address, issueQuantity, owner.address); + + // Trade into token with fee + await tradeModule.connect(manager.wallet).trade( + setToken.address, + uniswapTransferFeeAdapterName, + setup.wbtc.address, + wbtcSendQuantity, + tokenWithFee.address, + ZERO, + EMPTY_BYTES + ); + }); + + beforeEach(() => { + // Trade token with fee back to WBTC + subjectSourceToken = tokenWithFee.address; + subjectDestinationToken = setup.wbtc.address; + subjectSourceQuantity = sourceTokenQuantity; + subjectSetToken = setToken.address; + subjectAdapterName = uniswapTransferFeeAdapterName; + subjectData = EMPTY_BYTES; + subjectMinDestinationQuantity = ZERO; + subjectCaller = manager; + }); + + async function subject(): Promise { + tradeModule = tradeModule.connect(subjectCaller.wallet); + return tradeModule.trade( + subjectSetToken, + subjectAdapterName, + subjectSourceToken, + subjectSourceQuantity, + subjectDestinationToken, + subjectMinDestinationQuantity, + subjectData + ); + } + + it("should transfer the correct components to the SetToken", async () => { + const oldDestinationTokenBalance = await setup.wbtc.balanceOf(setToken.address); + const [, expectedReceiveQuantity] = await uniswapSetup.router.getAmountsOut( + subjectSourceQuantity.sub(ether(0.01)), // Sub transfer fee + [subjectSourceToken, subjectDestinationToken] + ); + + await subject(); + + const expectedDestinationTokenBalance = oldDestinationTokenBalance.add(expectedReceiveQuantity); + const newDestinationTokenBalance = await setup.wbtc.balanceOf(setToken.address); + expect(newDestinationTokenBalance).to.eq(expectedDestinationTokenBalance); + }); + + it("should transfer the correct components from the SetToken", async () => { + const oldSourceTokenBalance = await tokenWithFee.balanceOf(setToken.address); + + await subject(); + + const totalSourceQuantity = issueQuantity.mul(sourceTokenQuantity).div(ether(1)); + const expectedSourceTokenBalance = oldSourceTokenBalance.sub(totalSourceQuantity); + const newSourceTokenBalance = await tokenWithFee.balanceOf(setToken.address); + expect(newSourceTokenBalance).to.eq(expectedSourceTokenBalance); + }); + + it("should update the positions on the SetToken correctly", async () => { + const initialPositions = await setToken.getPositions(); + const [, expectedReceiveQuantity] = await uniswapSetup.router.getAmountsOut( + subjectSourceQuantity.sub(ether(0.01)), // Sub transfer fee + [subjectSourceToken, subjectDestinationToken] + ); + + await subject(); + + // All WBTC is sold for WETH + const currentPositions = await setToken.getPositions(); + const newSecondPosition = (await setToken.getPositions())[1]; + + expect(initialPositions.length).to.eq(1); + expect(currentPositions.length).to.eq(2); + expect(newSecondPosition.component).to.eq(subjectDestinationToken); + expect(newSecondPosition.unit).to.eq(expectedReceiveQuantity); + expect(newSecondPosition.module).to.eq(ADDRESS_ZERO); + }); + + describe("when path is through multiple trading pairs", async () => { + beforeEach(async () => { + await setup.wbtc.connect(owner.wallet).approve(uniswapSetup.router.address, bitcoin(1000)); + await setup.dai.connect(owner.wallet).approve(uniswapSetup.router.address, ether(1000000)); + await uniswapSetup.router.addLiquidity( + setup.wbtc.address, + setup.dai.address, + bitcoin(10), + ether(1000000), + ether(995), + ether(995000), + owner.address, + MAX_UINT_256 + ); + + subjectDestinationToken = setup.dai.address; + const tradePath = [subjectSourceToken, setup.wbtc.address, subjectDestinationToken]; + subjectData = defaultAbiCoder.encode( + ["address[]"], + [tradePath] + ); + }); + + it("should transfer the correct components to the SetToken", async () => { + const oldDestinationTokenBalance = await setup.dai.balanceOf(setToken.address); + const [, , expectedReceiveQuantity] = await uniswapSetup.router.getAmountsOut( + subjectSourceQuantity.sub(ether(0.01)), // Sub transfer fee + [subjectSourceToken, setup.wbtc.address, subjectDestinationToken] + ); + + await subject(); + + const expectedDestinationTokenBalance = oldDestinationTokenBalance.add(expectedReceiveQuantity); + const newDestinationTokenBalance = await setup.dai.balanceOf(setToken.address); + expect(newDestinationTokenBalance).to.eq(expectedDestinationTokenBalance); + }); + }); + }); + context("when trading a Default component on Uniswap version 2 adapter", async () => { cacheBeforeEach(async () => { await setup.weth.connect(owner.wallet).approve(uniswapSetup.router.address, ether(10000)); diff --git a/utils/contracts/index.ts b/utils/contracts/index.ts index 50069a357..a2adf31ae 100644 --- a/utils/contracts/index.ts +++ b/utils/contracts/index.ts @@ -82,6 +82,7 @@ export { Uint256ArrayUtilsMock } from "../../typechain/Uint256ArrayUtilsMock"; export { Uni } from "../../typechain/Uni"; export { UniswapPairPriceAdapter } from "../../typechain/UniswapPairPriceAdapter"; export { UniswapV2ExchangeAdapter } from "../../typechain/UniswapV2ExchangeAdapter"; +export { UniswapV2TransferFeeExchangeAdapter } from "../../typechain/UniswapV2TransferFeeExchangeAdapter"; export { UniswapV2ExchangeAdapterV2 } from "../../typechain/UniswapV2ExchangeAdapterV2"; export { UniswapV2Factory } from "../../typechain/UniswapV2Factory"; export { UniswapV2Pair } from "../../typechain/UniswapV2Pair"; diff --git a/utils/deploys/deployAdapters.ts b/utils/deploys/deployAdapters.ts index 85f2be534..c679ede86 100644 --- a/utils/deploys/deployAdapters.ts +++ b/utils/deploys/deployAdapters.ts @@ -13,6 +13,7 @@ import { YearnWrapAdapter, UniswapPairPriceAdapter, UniswapV2ExchangeAdapter, + UniswapV2TransferFeeExchangeAdapter, UniswapV2ExchangeAdapterV2, ZeroExApiAdapter, SnapshotGovernanceAdapter, @@ -36,6 +37,7 @@ import { CompoundWrapAdapter__factory } from "../../typechain/factories/Compound import { YearnWrapAdapter__factory } from "../../typechain/factories/YearnWrapAdapter__factory"; import { UniswapPairPriceAdapter__factory } from "../../typechain/factories/UniswapPairPriceAdapter__factory"; import { UniswapV2ExchangeAdapter__factory } from "../../typechain/factories/UniswapV2ExchangeAdapter__factory"; +import { UniswapV2TransferFeeExchangeAdapter__factory } from "../../typechain/factories/UniswapV2TransferFeeExchangeAdapter__factory"; import { UniswapV2ExchangeAdapterV2__factory } from "../../typechain/factories/UniswapV2ExchangeAdapterV2__factory"; import { SnapshotGovernanceAdapter__factory } from "../../typechain/factories/SnapshotGovernanceAdapter__factory"; import { SynthetixExchangeAdapter__factory } from "../../typechain/factories/SynthetixExchangeAdapter__factory"; @@ -68,6 +70,10 @@ export default class DeployAdapters { return await new UniswapV2ExchangeAdapter__factory(this._deployerSigner).deploy(uniswapV2Router); } + public async deployUniswapV2TransferFeeExchangeAdapter(uniswapV2Router: Address): Promise { + return await new UniswapV2TransferFeeExchangeAdapter__factory(this._deployerSigner).deploy(uniswapV2Router); + } + public async deployUniswapV2ExchangeAdapterV2(uniswapV2Router: Address): Promise { return await new UniswapV2ExchangeAdapterV2__factory(this._deployerSigner).deploy(uniswapV2Router); }