From acf83656feee5c629cd69b7bdedfdedd2075bd49 Mon Sep 17 00:00:00 2001 From: Pano Skylakis <49353965+pano-skylakis@users.noreply.github.com> Date: Fri, 27 Oct 2023 15:27:47 +1300 Subject: [PATCH] Feat: [TP-1627] Add support for swapping ERC20s into native tokens (#1078) Co-authored-by: Pano Skylakis --- ...ange.getUnsignedSwapTxFromAmountIn.test.ts | 1022 ++++++++++------- ...nge.getUnsignedSwapTxFromAmountOut.test.ts | 131 +++ packages/internal/dex/sdk/src/exchange.ts | 1 + .../sdk/src/lib/transactionUtils/swap.test.ts | 12 + .../dex/sdk/src/lib/transactionUtils/swap.ts | 184 +-- packages/internal/dex/sdk/src/lib/utils.ts | 2 + packages/internal/dex/sdk/src/test/utils.ts | 15 +- 7 files changed, 847 insertions(+), 520 deletions(-) diff --git a/packages/internal/dex/sdk/src/exchange.getUnsignedSwapTxFromAmountIn.test.ts b/packages/internal/dex/sdk/src/exchange.getUnsignedSwapTxFromAmountIn.test.ts index dd199f6b86..24fd67b3a3 100644 --- a/packages/internal/dex/sdk/src/exchange.getUnsignedSwapTxFromAmountIn.test.ts +++ b/packages/internal/dex/sdk/src/exchange.getUnsignedSwapTxFromAmountIn.test.ts @@ -7,6 +7,7 @@ import { constants, utils } from 'ethers'; import { SecondaryFee } from 'types'; import { Environment } from '@imtbl/config'; import { Router, addAmount } from 'lib'; +import { PaymentsExtended, SwapRouter } from '@uniswap/router-sdk'; import { IMMUTABLE_TESTNET_CHAIN_ID, TIMX_IMMUTABLE_TESTNET } from './constants'; import { Exchange } from './exchange'; import { @@ -38,6 +39,8 @@ import { FUN_TEST_TOKEN, nativeTokenService, TEST_FROM_ADDRESS, + expectToBeString, + decodeMulticallExactInputWithoutFees, } from './test/utils'; jest.mock('@ethersproject/providers'); @@ -162,7 +165,7 @@ describe('getUnsignedSwapTxFromAmountIn', () => { }); }); - describe('with a native token out', () => { + describe('with a single pool and a native token out', () => { it('should not include any amount as the value of the transaction', async () => { mockRouterImplementation({ pools: [createPool(nativeTokenService.wrappedToken, FUN_TEST_TOKEN)], @@ -206,282 +209,241 @@ describe('getUnsignedSwapTxFromAmountIn', () => { expect(result.quote.amount.token.chainId).toEqual(nativeTokenService.nativeToken.chainId); expect(result.quote.amount.token.decimals).toEqual(nativeTokenService.nativeToken.decimals); }); - }); - describe('When the swap transaction requires approval', () => { - it('should include the unsigned approval transaction', async () => { - const params = setupSwapTxTest(); - mockRouterImplementation(params); - const erc20ContractInterface = ERC20__factory.createInterface(); + it('should include a call to unwrapWETH9 as the final method call of the calldata', async () => { + mockRouterImplementation({ + pools: [createPool(nativeTokenService.wrappedToken, FUN_TEST_TOKEN)], + }); + const swapRouterInterface = SwapRouter.INTERFACE; + const paymentsInterface = PaymentsExtended.INTERFACE; const exchange = new Exchange(TEST_DEX_CONFIGURATION); - const amountIn = addAmount(APPROVED_AMOUNT, newAmountFromString('1', USDC_TEST_TOKEN)); - const tx = await exchange.getUnsignedSwapTxFromAmountIn( - params.fromAddress, - params.inputToken, - params.outputToken, - amountIn.value, + // Buy 100 native tokens for X amount of FUN where the exchange rate is 1 token-in : 10 token-out + const { swap } = await exchange.getUnsignedSwapTxFromAmountIn( + TEST_FROM_ADDRESS, + FUN_TEST_TOKEN.address, + 'native', + newAmountFromString('100', NATIVE_TEST_TOKEN).value, ); - expectToBeDefined(tx.approval?.transaction.data); + expectToBeDefined(swap.transaction.data); + expectToBeDefined(swap.transaction.value); + const calldata = swap.transaction.data.toString(); - const decodedResults = erc20ContractInterface.decodeFunctionData('approve', tx.approval.transaction.data); - expect(decodedResults[0]).toEqual(TEST_ROUTER_ADDRESS); - // we have already approved 1000000000000000000, so we expect to approve 1000000000000000000 more - expect(decodedResults[1].toString()).toEqual(APPROVED_AMOUNT.value.toString()); - expect(tx.approval.transaction.to).toEqual(params.inputToken); - expect(tx.approval.transaction.from).toEqual(params.fromAddress); - expect(tx.approval.transaction.value).toEqual(0); // we do not want to send any ETH - }); + const topLevelParams = swapRouterInterface.decodeFunctionData('multicall(uint256,bytes[])', calldata); - it('should include the gas estimate for the approval transaction', async () => { - const params = setupSwapTxTest(); - mockRouterImplementation(params); + expect(topLevelParams.data.length).toBe(2); // expect that there are two calls in the multicall + const swapFunctionCalldata = topLevelParams.data[0]; + const unwrapWETHFunctionCalldata = topLevelParams.data[1]; - const exchange = new Exchange(TEST_DEX_CONFIGURATION); + expectToBeString(swapFunctionCalldata); + expectToBeString(unwrapWETHFunctionCalldata); - const amountIn = addAmount(APPROVED_AMOUNT, newAmountFromString('1', USDC_TEST_TOKEN)); - const tx = await exchange.getUnsignedSwapTxFromAmountIn( - params.fromAddress, - params.inputToken, - params.outputToken, - amountIn.value, - ); + // Get the first 4 bytes of the swap and unwrap function calldata to get the function selector + const swapFunctionFragment = swapRouterInterface.getFunction(swapFunctionCalldata.slice(0, 10)); + const unwrapFunctionFragment = paymentsInterface.getFunction(unwrapWETHFunctionCalldata.slice(0, 10)); - expectToBeDefined(tx.approval?.gasFeeEstimate); - expect(tx.approval.gasFeeEstimate.value).toEqual(TEST_GAS_PRICE.mul(APPROVE_GAS_ESTIMATE)); - expect(tx.approval.gasFeeEstimate.token.chainId).toEqual(NATIVE_TEST_TOKEN.chainId); - expect(tx.approval.gasFeeEstimate.token.address).toEqual(''); - expect(tx.approval.gasFeeEstimate.token.decimals).toEqual(NATIVE_TEST_TOKEN.decimals); - expect(tx.approval.gasFeeEstimate.token.symbol).toEqual(NATIVE_TEST_TOKEN.symbol); - expect(tx.approval.gasFeeEstimate.token.name).toEqual(NATIVE_TEST_TOKEN.name); + expect(swapFunctionFragment.name).toEqual('exactInputSingle'); + expect(unwrapFunctionFragment.name).toEqual('unwrapWETH9'); }); - }); - describe('When the swap transaction does not require approval', () => { - it('should not include the unsigned approval transaction', async () => { - const params = setupSwapTxTest(); - mockRouterImplementation(params); + it('should specify the quoted amount with slippage applied in the unwrapWETH9 function calldata', async () => { + mockRouterImplementation({ + pools: [createPool(nativeTokenService.wrappedToken, FUN_TEST_TOKEN)], + }); + const swapRouterInterface = SwapRouter.INTERFACE; + const paymentsInterface = PaymentsExtended.INTERFACE; const exchange = new Exchange(TEST_DEX_CONFIGURATION); - // Set the amountIn to be the same as the APPROVED_AMOUNT - const tx = await exchange.getUnsignedSwapTxFromAmountIn( - params.fromAddress, - params.inputToken, - params.outputToken, - APPROVED_AMOUNT.value, + // Buy 100 native tokens for X amount of FUN where the exchange rate is 1 token-in : 10 token-out + const { swap } = await exchange.getUnsignedSwapTxFromAmountIn( + TEST_FROM_ADDRESS, + FUN_TEST_TOKEN.address, + 'native', + newAmountFromString('100', NATIVE_TEST_TOKEN).value, + 10, // 10 % slippage for easier test math ); - // we have already approved 1000000000000000000, so we don't expect to approve anything - expect(tx.approval).toBe(null); - }); - }); + expectToBeDefined(swap.transaction.data); + expectToBeDefined(swap.transaction.value); + const calldata = swap.transaction.data.toString(); - describe('When no route found', () => { - it('throws NoRoutesAvailableError', async () => { - const params = setupSwapTxTest(); + const topLevelParams = swapRouterInterface.decodeFunctionData('multicall(uint256,bytes[])', calldata); - (Router as unknown as jest.Mock).mockImplementationOnce(() => ({ - findOptimalRoute: jest.fn().mockRejectedValue(new NoRoutesAvailableError()), - })); + expect(topLevelParams.data.length).toBe(2); // expect that there are two calls in the multicall + const swapFunctionCalldata = topLevelParams.data[0]; + const unwrapWETHFunctionCalldata = topLevelParams.data[1]; - const exchange = new Exchange(TEST_DEX_CONFIGURATION); - await expect( - exchange.getUnsignedSwapTxFromAmountIn( - params.fromAddress, - params.inputToken, - params.outputToken, - newAmountFromString('100', USDC_TEST_TOKEN).value, - ), - ).rejects.toThrow(new NoRoutesAvailableError()); - }); - }); + expectToBeString(swapFunctionCalldata); + expectToBeString(unwrapWETHFunctionCalldata); - describe('Swap with single pool and secondary fees', () => { - it('generates valid swap calldata', async () => { - const params = setupSwapTxTest(); - const findOptimalRouteMock = mockRouterImplementation(params); - - const secondaryFees: SecondaryFee[] = [ - { recipient: TEST_FEE_RECIPIENT, basisPoints: 100 }, // 1% Fee - ]; - const exchange = new Exchange({ ...TEST_DEX_CONFIGURATION, secondaryFees }); - - const { swap, quote } = await exchange.getUnsignedSwapTxFromAmountIn( - params.fromAddress, - params.inputToken, - params.outputToken, - newAmountFromString('100', USDC_TEST_TOKEN).value, - 3, // 3% Slippage + const decodedUnwrapWETH9FunctionData = paymentsInterface.decodeFunctionData( + 'unwrapWETH9(uint256)', + unwrapWETHFunctionCalldata, ); - expectToBeDefined(swap.transaction.data); - - expect(formatAmount(quote.amountWithMaxSlippage)).toEqual('961.165048543689320388'); // userQuoteRes.amountOutMinimum = swapReq.amountOutMinimum + expect(formatEther(decodedUnwrapWETH9FunctionData.toString())).toEqual('909.090909090909090909'); // expect the quoted amount with slippage applied i.e. minimum amount out + }); - const ourQuoteReqAmountIn = findOptimalRouteMock.mock.calls[0][0]; - expect(formatAmount(ourQuoteReqAmountIn)).toEqual('99.0'); // ourQuoteReq.amountIn = the amount specified less the fee + it('should specify the Router contract as the recipient of the swap function call', async () => { + mockRouterImplementation({ + pools: [createPool(nativeTokenService.wrappedToken, FUN_TEST_TOKEN)], + }); - const data = swap.transaction.data.toString(); + const exchange = new Exchange(TEST_DEX_CONFIGURATION); - const { swapParams, secondaryFeeParams } = decodeMulticallExactInputSingleWithFees(data); - expectInstanceOf(BigNumber, swapParams.amountIn); + // Buy 100 native tokens for X amount of FUN where the exchange rate is 1 token-in : 10 token-out + const { swap } = await exchange.getUnsignedSwapTxFromAmountIn( + TEST_FROM_ADDRESS, + FUN_TEST_TOKEN.address, + 'native', + newAmountFromString('100', NATIVE_TEST_TOKEN).value, + 3, // 3 % slippage + ); - expect(secondaryFeeParams[0].recipient).toBe(TEST_FEE_RECIPIENT); - expect(secondaryFeeParams[0].basisPoints.toString()).toBe('100'); + expectToBeDefined(swap.transaction.data); + expectToBeDefined(swap.transaction.value); - expect(swapParams.tokenIn).toBe(params.inputToken); - expect(swapParams.tokenOut).toBe(params.outputToken); - expect(swapParams.fee).toBe(10000); - expect(swapParams.recipient).toBe(params.fromAddress); - expect(formatTokenAmount(swapParams.amountIn, USDC_TEST_TOKEN)).toBe('100.0'); // swap.amountIn = userQuoteReq.amountIn - expect(formatEther(swapParams.amountOutMinimum)).toBe('961.165048543689320388'); // swap.amountOutMinimum = ourQuoteRes.amountOut - slippage - expect(swapParams.sqrtPriceLimitX96.toString()).toBe('0'); + const { swapParams } = decodeMulticallExactInputSingleWithoutFees(swap.transaction.data); - expect(swap.transaction.to).toBe(TEST_SECONDARY_FEE_ADDRESS); - expect(swap.transaction.from).toBe(params.fromAddress); - expect(swap.transaction.value).toBe('0x00'); + expect(swapParams.recipient).toEqual(TEST_ROUTER_ADDRESS); }); }); - describe('Swap with multiple pools and secondary fees', () => { - it('generates valid swap calldata', async () => { - const params = setupSwapTxTest({ multiPoolSwap: true }); - mockRouterImplementation(params); + describe('with multiple pools and a native token out', () => { + describe('with a native token out', () => { + it('should specify the Router contract as the recipient of the swap function call', async () => { + mockRouterImplementation({ + pools: [ + createPool(USDC_TEST_TOKEN, FUN_TEST_TOKEN), + createPool(nativeTokenService.wrappedToken, USDC_TEST_TOKEN), + ], + }); + + const exchange = new Exchange(TEST_DEX_CONFIGURATION); + + // Buy 100 native tokens for X amount of FUN where the exchange rate is 1 token-in : 10 token-out + // Route is FUN > USDC > WIMX + const { swap } = await exchange.getUnsignedSwapTxFromAmountIn( + TEST_FROM_ADDRESS, + FUN_TEST_TOKEN.address, + 'native', + newAmountFromString('100', NATIVE_TEST_TOKEN).value, + 3, // 3 % slippage + ); - const secondaryFees: SecondaryFee[] = [{ recipient: TEST_FEE_RECIPIENT, basisPoints: TEST_MAX_FEE_BASIS_POINTS }]; + expectToBeDefined(swap.transaction.data); + expectToBeDefined(swap.transaction.value); - const exchange = new Exchange({ ...TEST_DEX_CONFIGURATION, secondaryFees }); + const { swapParams } = decodeMulticallExactInputWithoutFees(swap.transaction.data); - const { swap } = await exchange.getUnsignedSwapTxFromAmountIn( - params.fromAddress, - params.inputToken, - params.outputToken, - newAmountFromString('100', USDC_TEST_TOKEN).value, - ); - - expectToBeDefined(swap.transaction.data); + expect(swapParams.recipient).toEqual(TEST_ROUTER_ADDRESS); + }); + }); - const data = swap.transaction.data.toString(); + describe('When the swap transaction requires approval', () => { + it('should include the unsigned approval transaction', async () => { + const params = setupSwapTxTest(); + mockRouterImplementation(params); + const erc20ContractInterface = ERC20__factory.createInterface(); - const { swapParams, secondaryFeeParams } = decodeMulticallExactInputWithFees(data); - expectInstanceOf(BigNumber, swapParams.amountIn); + const exchange = new Exchange(TEST_DEX_CONFIGURATION); - expect(secondaryFeeParams[0].recipient).toBe(TEST_FEE_RECIPIENT); - expect(secondaryFeeParams[0].basisPoints.toString()).toBe(TEST_MAX_FEE_BASIS_POINTS.toString()); + const amountIn = addAmount(APPROVED_AMOUNT, newAmountFromString('1', USDC_TEST_TOKEN)); + const tx = await exchange.getUnsignedSwapTxFromAmountIn( + params.fromAddress, + params.inputToken, + params.outputToken, + amountIn.value, + ); - const decodedPath = decodePathForExactInput(swapParams.path.toString()); + expectToBeDefined(tx.approval?.transaction.data); - expect(swap.transaction.to).toBe(TEST_SECONDARY_FEE_ADDRESS); // to address - expect(swap.transaction.from).toBe(params.fromAddress); // from address - expect(swap.transaction.value).toBe('0x00'); // refers to 0 amount of the native token + const decodedResults = erc20ContractInterface.decodeFunctionData('approve', tx.approval.transaction.data); + expect(decodedResults[0]).toEqual(TEST_ROUTER_ADDRESS); + // we have already approved 1000000000000000000, so we expect to approve 1000000000000000000 more + expect(decodedResults[1].toString()).toEqual(APPROVED_AMOUNT.value.toString()); + expect(tx.approval.transaction.to).toEqual(params.inputToken); + expect(tx.approval.transaction.from).toEqual(params.fromAddress); + expect(tx.approval.transaction.value).toEqual(0); // we do not want to send any ETH + }); - expect(utils.getAddress(decodedPath.inputToken)).toBe(params.inputToken); - expect(utils.getAddress(decodedPath.intermediaryToken)).toBe(params.intermediaryToken); - expect(utils.getAddress(decodedPath.outputToken)).toBe(params.outputToken); - expect(decodedPath.firstPoolFee.toString()).toBe('10000'); - expect(decodedPath.secondPoolFee.toString()).toBe('10000'); + it('should include the gas estimate for the approval transaction', async () => { + const params = setupSwapTxTest(); + mockRouterImplementation(params); - expect(swapParams.recipient).toBe(params.fromAddress); // recipient of swap - expect(formatTokenAmount(swapParams.amountIn, USDC_TEST_TOKEN)).toBe('100.0'); - expect(formatEther(swapParams.amountOutMinimum)).toBe('899.100899100899100899'); // includes slippage and fees - }); + const exchange = new Exchange(TEST_DEX_CONFIGURATION); - it('returns a quote', async () => { - const params = setupSwapTxTest({ multiPoolSwap: true }); - mockRouterImplementation(params); + const amountIn = addAmount(APPROVED_AMOUNT, newAmountFromString('1', USDC_TEST_TOKEN)); + const tx = await exchange.getUnsignedSwapTxFromAmountIn( + params.fromAddress, + params.inputToken, + params.outputToken, + amountIn.value, + ); - const secondaryFees: SecondaryFee[] = [{ recipient: TEST_FEE_RECIPIENT, basisPoints: TEST_MAX_FEE_BASIS_POINTS }]; + expectToBeDefined(tx.approval?.gasFeeEstimate); + expect(tx.approval.gasFeeEstimate.value).toEqual(TEST_GAS_PRICE.mul(APPROVE_GAS_ESTIMATE)); + expect(tx.approval.gasFeeEstimate.token.chainId).toEqual(NATIVE_TEST_TOKEN.chainId); + expect(tx.approval.gasFeeEstimate.token.address).toEqual(''); + expect(tx.approval.gasFeeEstimate.token.decimals).toEqual(NATIVE_TEST_TOKEN.decimals); + expect(tx.approval.gasFeeEstimate.token.symbol).toEqual(NATIVE_TEST_TOKEN.symbol); + expect(tx.approval.gasFeeEstimate.token.name).toEqual(NATIVE_TEST_TOKEN.name); + }); + }); - const exchange = new Exchange({ ...TEST_DEX_CONFIGURATION, secondaryFees }); + describe('When the swap transaction does not require approval', () => { + it('should not include the unsigned approval transaction', async () => { + const params = setupSwapTxTest(); + mockRouterImplementation(params); - const { quote } = await exchange.getUnsignedSwapTxFromAmountIn( - params.fromAddress, - params.inputToken, - params.outputToken, - newAmountFromString('100', USDC_TEST_TOKEN).value, - ); + const exchange = new Exchange(TEST_DEX_CONFIGURATION); - const tokenIn = { ...USDC_TEST_TOKEN, name: undefined, symbol: undefined }; + // Set the amountIn to be the same as the APPROVED_AMOUNT + const tx = await exchange.getUnsignedSwapTxFromAmountIn( + params.fromAddress, + params.inputToken, + params.outputToken, + APPROVED_AMOUNT.value, + ); - expect(quote.fees).toEqual([ - { - recipient: TEST_FEE_RECIPIENT, - basisPoints: TEST_MAX_FEE_BASIS_POINTS, - amount: newAmountFromString('10', tokenIn), - }, - ]); + // we have already approved 1000000000000000000, so we don't expect to approve anything + expect(tx.approval).toBe(null); + }); }); - }); - describe('Swap with secondary fees and paused secondary fee contract', () => { - it('should use the default router contract with no fees applied to the swap', async () => { - erc20Contract = (Contract as unknown as jest.Mock).mockImplementation(() => ({ - allowance: jest.fn().mockResolvedValue(APPROVED_AMOUNT.value), - estimateGas: { approve: jest.fn().mockResolvedValue(APPROVE_GAS_ESTIMATE) }, - paused: jest.fn().mockResolvedValue(true), - })); - - const params = setupSwapTxTest(); - mockRouterImplementation(params); - - const secondaryFees: SecondaryFee[] = [ - { recipient: TEST_FEE_RECIPIENT, basisPoints: 100 }, // 1% Fee - ]; - const exchange = new Exchange({ ...TEST_DEX_CONFIGURATION, secondaryFees }); - - const { swap, quote } = await exchange.getUnsignedSwapTxFromAmountIn( - params.fromAddress, - params.inputToken, - params.outputToken, - newAmountFromString('100', USDC_TEST_TOKEN).value, - ); - - expectToBeDefined(swap.transaction.data); - - expect(formatAmount(quote.amountWithMaxSlippage)).toEqual('999.000999000999000999'); // min amount out (includes slippage) - expect(quote.fees.length).toBe(0); // expect no fees to be applied + describe('When no route found', () => { + it('throws NoRoutesAvailableError', async () => { + const params = setupSwapTxTest(); - const data = swap.transaction.data.toString(); - const { swapParams } = decodeMulticallExactInputSingleWithoutFees(data); + (Router as unknown as jest.Mock).mockImplementationOnce(() => ({ + findOptimalRoute: jest.fn().mockRejectedValue(new NoRoutesAvailableError()), + })); - expect(formatEther(swapParams.amountOutMinimum)).toBe(formatEther(quote.amountWithMaxSlippage.value)); - expect(swap.transaction.to).toBe(TEST_ROUTER_ADDRESS); // expect the default router contract to be used + const exchange = new Exchange(TEST_DEX_CONFIGURATION); + await expect( + exchange.getUnsignedSwapTxFromAmountIn( + params.fromAddress, + params.inputToken, + params.outputToken, + newAmountFromString('100', USDC_TEST_TOKEN).value, + ), + ).rejects.toThrow(new NoRoutesAvailableError()); + }); }); - describe('when the secondary fee contract is unpaused after a swap request', () => { - it('should apply secondary fees to a subsequent swap request', async () => { - erc20Contract = (Contract as unknown as jest.Mock).mockImplementation(() => ({ - allowance: jest.fn().mockResolvedValue(APPROVED_AMOUNT.value), - estimateGas: { approve: jest.fn().mockResolvedValue(APPROVE_GAS_ESTIMATE) }, - paused: jest.fn().mockResolvedValue(true), - })); - + describe('Swap with single pool and secondary fees', () => { + it('generates valid swap calldata', async () => { const params = setupSwapTxTest(); - mockRouterImplementation(params); + const findOptimalRouteMock = mockRouterImplementation(params); const secondaryFees: SecondaryFee[] = [ { recipient: TEST_FEE_RECIPIENT, basisPoints: 100 }, // 1% Fee ]; const exchange = new Exchange({ ...TEST_DEX_CONFIGURATION, secondaryFees }); - await exchange.getUnsignedSwapTxFromAmountIn( - params.fromAddress, - params.inputToken, - params.outputToken, - newAmountFromString('100', USDC_TEST_TOKEN).value, - 3, // 3% Slippage - ); - - // Unpause the secondary fee contract - erc20Contract = (Contract as unknown as jest.Mock).mockImplementation(() => ({ - allowance: jest.fn().mockResolvedValue(APPROVED_AMOUNT.value), - estimateGas: { approve: jest.fn().mockResolvedValue(APPROVE_GAS_ESTIMATE) }, - paused: jest.fn().mockResolvedValue(false), - })); - const { swap, quote } = await exchange.getUnsignedSwapTxFromAmountIn( params.fromAddress, params.inputToken, @@ -492,312 +454,490 @@ describe('getUnsignedSwapTxFromAmountIn', () => { expectToBeDefined(swap.transaction.data); - expect(formatAmount(quote.amountWithMaxSlippage)).toEqual('961.165048543689320388'); // min amount out (includes slippage) - expect(quote.fees.length).toBe(1); // expect no fees to be applied + expect(formatAmount(quote.amountWithMaxSlippage)).toEqual('961.165048543689320388'); // userQuoteRes.amountOutMinimum = swapReq.amountOutMinimum + + const ourQuoteReqAmountIn = findOptimalRouteMock.mock.calls[0][0]; + expect(formatAmount(ourQuoteReqAmountIn)).toEqual('99.0'); // ourQuoteReq.amountIn = the amount specified less the fee const data = swap.transaction.data.toString(); - const { swapParams } = decodeMulticallExactInputSingleWithFees(data); - expect(formatEther(swapParams.amountOutMinimum)).toBe(formatEther(quote.amountWithMaxSlippage.value)); - expect(swap.transaction.to).toBe(TEST_SECONDARY_FEE_ADDRESS); // expect the secondary fee contract to be used - }); - }); - }); + const { swapParams, secondaryFeeParams } = decodeMulticallExactInputSingleWithFees(data); + expectInstanceOf(BigNumber, swapParams.amountIn); - describe('Swap with single pool without fees and default slippage tolerance', () => { - it('generates valid swap calldata', async () => { - const params = setupSwapTxTest(); + expect(secondaryFeeParams[0].recipient).toBe(TEST_FEE_RECIPIENT); + expect(secondaryFeeParams[0].basisPoints.toString()).toBe('100'); - mockRouterImplementation(params); + expect(swapParams.tokenIn).toBe(params.inputToken); + expect(swapParams.tokenOut).toBe(params.outputToken); + expect(swapParams.fee).toBe(10000); + expect(swapParams.recipient).toBe(params.fromAddress); + expect(formatTokenAmount(swapParams.amountIn, USDC_TEST_TOKEN)).toBe('100.0'); // swap.amountIn = userQuoteReq.amountIn + expect(formatEther(swapParams.amountOutMinimum)).toBe('961.165048543689320388'); // swap.amountOutMinimum = ourQuoteRes.amountOut - slippage + expect(swapParams.sqrtPriceLimitX96.toString()).toBe('0'); - const exchange = new Exchange(TEST_DEX_CONFIGURATION); + expect(swap.transaction.to).toBe(TEST_SECONDARY_FEE_ADDRESS); + expect(swap.transaction.from).toBe(params.fromAddress); + expect(swap.transaction.value).toBe('0x00'); + }); + }); - const { swap } = await exchange.getUnsignedSwapTxFromAmountIn( - params.fromAddress, - params.inputToken, - params.outputToken, - newAmountFromString('100', USDC_TEST_TOKEN).value, - ); + describe('Swap with multiple pools and secondary fees', () => { + it('generates valid swap calldata', async () => { + const params = setupSwapTxTest({ multiPoolSwap: true }); + mockRouterImplementation(params); - expectToBeDefined(swap.transaction.data); + const secondaryFees: SecondaryFee[] = [ + { recipient: TEST_FEE_RECIPIENT, basisPoints: TEST_MAX_FEE_BASIS_POINTS }, + ]; - const data = swap.transaction.data.toString(); + const exchange = new Exchange({ ...TEST_DEX_CONFIGURATION, secondaryFees }); - const { swapParams } = decodeMulticallExactInputSingleWithoutFees(data); - expectInstanceOf(BigNumber, swapParams.amountIn); + const { swap } = await exchange.getUnsignedSwapTxFromAmountIn( + params.fromAddress, + params.inputToken, + params.outputToken, + newAmountFromString('100', USDC_TEST_TOKEN).value, + ); - expect(swapParams.tokenIn).toBe(params.inputToken); // input token - expect(swapParams.tokenOut).toBe(params.outputToken); // output token - expect(swapParams.fee).toBe(10000); // fee - expect(swapParams.recipient).toBe(params.fromAddress); // recipient - expect(swap.transaction.to).toBe(TEST_ROUTER_ADDRESS); // to address - expect(swap.transaction.from).toBe(params.fromAddress); // from address - expect(swap.transaction.value).toBe('0x00'); // refers to 0ETH - expect(formatTokenAmount(swapParams.amountIn, USDC_TEST_TOKEN)).toBe('100.0'); // amount in - expect(formatEther(swapParams.amountOutMinimum)).toBe('999.000999000999000999'); // min amount out (includes slippage) - expect(swapParams.sqrtPriceLimitX96.toString()).toBe('0'); // sqrtPriceX96Limit - }); + expectToBeDefined(swap.transaction.data); - it('returns the gas estimate for the swap', async () => { - const params = setupSwapTxTest(); + const data = swap.transaction.data.toString(); - mockRouterImplementation(params); + const { swapParams, secondaryFeeParams } = decodeMulticallExactInputWithFees(data); + expectInstanceOf(BigNumber, swapParams.amountIn); - const exchange = new Exchange(TEST_DEX_CONFIGURATION); + expect(secondaryFeeParams[0].recipient).toBe(TEST_FEE_RECIPIENT); + expect(secondaryFeeParams[0].basisPoints.toString()).toBe(TEST_MAX_FEE_BASIS_POINTS.toString()); - const tx = await exchange.getUnsignedSwapTxFromAmountIn( - params.fromAddress, - params.inputToken, - params.outputToken, - newAmountFromString('100', USDC_TEST_TOKEN).value, - ); + const decodedPath = decodePathForExactInput(swapParams.path.toString()); - expectToBeDefined(tx.swap.gasFeeEstimate); + expect(swap.transaction.to).toBe(TEST_SECONDARY_FEE_ADDRESS); // to address + expect(swap.transaction.from).toBe(params.fromAddress); // from address + expect(swap.transaction.value).toBe('0x00'); // refers to 0 amount of the native token - expect(tx.swap.gasFeeEstimate.value).toEqual(TEST_TRANSACTION_GAS_USAGE.mul(TEST_GAS_PRICE)); - expect(tx.swap.gasFeeEstimate.token.chainId).toEqual(NATIVE_TEST_TOKEN.chainId); - expect(tx.swap.gasFeeEstimate.token.address).toEqual(''); // Default configuration is a native token for gas and not an ERC20 - expect(tx.swap.gasFeeEstimate.token.decimals).toEqual(NATIVE_TEST_TOKEN.decimals); - expect(tx.swap.gasFeeEstimate.token.symbol).toEqual(NATIVE_TEST_TOKEN.symbol); - expect(tx.swap.gasFeeEstimate.token.name).toEqual(NATIVE_TEST_TOKEN.name); - }); + expect(utils.getAddress(decodedPath.inputToken)).toBe(params.inputToken); + expect(utils.getAddress(decodedPath.intermediaryToken)).toBe(params.intermediaryToken); + expect(utils.getAddress(decodedPath.outputToken)).toBe(params.outputToken); + expect(decodedPath.firstPoolFee.toString()).toBe('10000'); + expect(decodedPath.secondPoolFee.toString()).toBe('10000'); - it('returns valid quote', async () => { - const params = setupSwapTxTest(); + expect(swapParams.recipient).toBe(params.fromAddress); // recipient of swap + expect(formatTokenAmount(swapParams.amountIn, USDC_TEST_TOKEN)).toBe('100.0'); + expect(formatEther(swapParams.amountOutMinimum)).toBe('899.100899100899100899'); // includes slippage and fees + }); - mockRouterImplementation(params); + it('returns a quote', async () => { + const params = setupSwapTxTest({ multiPoolSwap: true }); + mockRouterImplementation(params); - const exchange = new Exchange(TEST_DEX_CONFIGURATION); + const secondaryFees: SecondaryFee[] = [ + { recipient: TEST_FEE_RECIPIENT, basisPoints: TEST_MAX_FEE_BASIS_POINTS }, + ]; - const { quote } = await exchange.getUnsignedSwapTxFromAmountIn( - params.fromAddress, - params.inputToken, - params.outputToken, - newAmountFromString('100', USDC_TEST_TOKEN).value, - ); + const exchange = new Exchange({ ...TEST_DEX_CONFIGURATION, secondaryFees }); - expect(quote).not.toBe(undefined); - expect(quote.amount.token.address).toEqual(params.outputToken); - expect(quote.slippage).toBe(0.1); - expect(formatAmount(quote.amount)).toEqual('1000.0'); - expect(quote.amountWithMaxSlippage.token.address).toEqual(params.outputToken); - expect(formatAmount(quote.amountWithMaxSlippage)).toEqual('999.000999000999000999'); // includes slippage - }); - }); + const { quote } = await exchange.getUnsignedSwapTxFromAmountIn( + params.fromAddress, + params.inputToken, + params.outputToken, + newAmountFromString('100', USDC_TEST_TOKEN).value, + ); - describe('Swap with single pool without fees and high slippage tolerance', () => { - it('generates valid calldata', async () => { - const params = setupSwapTxTest(); - mockRouterImplementation(params); + const tokenIn = { ...USDC_TEST_TOKEN, name: undefined, symbol: undefined }; - const exchange = new Exchange(TEST_DEX_CONFIGURATION); + expect(quote.fees).toEqual([ + { + recipient: TEST_FEE_RECIPIENT, + basisPoints: TEST_MAX_FEE_BASIS_POINTS, + amount: newAmountFromString('10', tokenIn), + }, + ]); + }); + }); - const { swap } = await exchange.getUnsignedSwapTxFromAmountIn( - params.fromAddress, - params.inputToken, - params.outputToken, - newAmountFromString('100', USDC_TEST_TOKEN).value, - HIGHER_SLIPPAGE, - ); + describe('Swap with secondary fees and paused secondary fee contract', () => { + it('should use the default router contract with no fees applied to the swap', async () => { + erc20Contract = (Contract as unknown as jest.Mock).mockImplementation(() => ({ + allowance: jest.fn().mockResolvedValue(APPROVED_AMOUNT.value), + estimateGas: { approve: jest.fn().mockResolvedValue(APPROVE_GAS_ESTIMATE) }, + paused: jest.fn().mockResolvedValue(true), + })); - expectToBeDefined(swap.transaction.data); + const params = setupSwapTxTest(); + mockRouterImplementation(params); - const data = swap.transaction.data.toString(); + const secondaryFees: SecondaryFee[] = [ + { recipient: TEST_FEE_RECIPIENT, basisPoints: 100 }, // 1% Fee + ]; + const exchange = new Exchange({ ...TEST_DEX_CONFIGURATION, secondaryFees }); - const { swapParams } = decodeMulticallExactInputSingleWithoutFees(data); - expectInstanceOf(BigNumber, swapParams.amountIn); + const { swap, quote } = await exchange.getUnsignedSwapTxFromAmountIn( + params.fromAddress, + params.inputToken, + params.outputToken, + newAmountFromString('100', USDC_TEST_TOKEN).value, + ); - expect(swapParams.tokenIn).toBe(params.inputToken); // input token - expect(swapParams.tokenOut).toBe(params.outputToken); // output token - expect(swapParams.fee).toBe(10000); // fee - expect(swapParams.recipient).toBe(params.fromAddress); // recipient - expect(swap.transaction.to).toBe(TEST_ROUTER_ADDRESS); // to address - expect(swap.transaction.from).toBe(params.fromAddress); // from address - expect(swap.transaction.value).toBe('0x00'); // refers to 0ETH - expect(formatTokenAmount(swapParams.amountIn, USDC_TEST_TOKEN)).toBe('100.0'); // amount in - expect(formatEther(swapParams.amountOutMinimum)).toBe('998.003992015968063872'); // min amount out (includes 0.2% slippage) - expect(swapParams.sqrtPriceLimitX96.toString()).toBe('0'); // sqrtPriceX96Limit - }); + expectToBeDefined(swap.transaction.data); - it('returns valid quote', async () => { - const params = setupSwapTxTest(); - mockRouterImplementation(params); + expect(formatAmount(quote.amountWithMaxSlippage)).toEqual('999.000999000999000999'); // min amount out (includes slippage) + expect(quote.fees.length).toBe(0); // expect no fees to be applied - const exchange = new Exchange(TEST_DEX_CONFIGURATION); + const data = swap.transaction.data.toString(); + const { swapParams } = decodeMulticallExactInputSingleWithoutFees(data); - const { quote } = await exchange.getUnsignedSwapTxFromAmountIn( - params.fromAddress, - params.inputToken, - params.outputToken, - newAmountFromString('100', USDC_TEST_TOKEN).value, - HIGHER_SLIPPAGE, - ); + expect(formatEther(swapParams.amountOutMinimum)).toBe(formatEther(quote.amountWithMaxSlippage.value)); + expect(swap.transaction.to).toBe(TEST_ROUTER_ADDRESS); // expect the default router contract to be used + }); - expect(quote.amount.token.address).toEqual(params.outputToken); - expect(quote.slippage).toBe(0.2); - expect(formatAmount(quote.amount)).toEqual('1000.0'); - expect(quote.amountWithMaxSlippage.token.address).toEqual(params.outputToken); - expect(formatAmount(quote.amountWithMaxSlippage)).toEqual('998.003992015968063872'); // includes 0.2% slippage + describe('when the secondary fee contract is unpaused after a swap request', () => { + it('should apply secondary fees to a subsequent swap request', async () => { + erc20Contract = (Contract as unknown as jest.Mock).mockImplementation(() => ({ + allowance: jest.fn().mockResolvedValue(APPROVED_AMOUNT.value), + estimateGas: { approve: jest.fn().mockResolvedValue(APPROVE_GAS_ESTIMATE) }, + paused: jest.fn().mockResolvedValue(true), + })); + + const params = setupSwapTxTest(); + mockRouterImplementation(params); + + const secondaryFees: SecondaryFee[] = [ + { recipient: TEST_FEE_RECIPIENT, basisPoints: 100 }, // 1% Fee + ]; + const exchange = new Exchange({ ...TEST_DEX_CONFIGURATION, secondaryFees }); + + await exchange.getUnsignedSwapTxFromAmountIn( + params.fromAddress, + params.inputToken, + params.outputToken, + newAmountFromString('100', USDC_TEST_TOKEN).value, + 3, // 3% Slippage + ); + + // Unpause the secondary fee contract + erc20Contract = (Contract as unknown as jest.Mock).mockImplementation(() => ({ + allowance: jest.fn().mockResolvedValue(APPROVED_AMOUNT.value), + estimateGas: { approve: jest.fn().mockResolvedValue(APPROVE_GAS_ESTIMATE) }, + paused: jest.fn().mockResolvedValue(false), + })); + + const { swap, quote } = await exchange.getUnsignedSwapTxFromAmountIn( + params.fromAddress, + params.inputToken, + params.outputToken, + newAmountFromString('100', USDC_TEST_TOKEN).value, + 3, // 3% Slippage + ); + + expectToBeDefined(swap.transaction.data); + + expect(formatAmount(quote.amountWithMaxSlippage)).toEqual('961.165048543689320388'); // min amount out (includes slippage) + expect(quote.fees.length).toBe(1); // expect no fees to be applied + + const data = swap.transaction.data.toString(); + const { swapParams } = decodeMulticallExactInputSingleWithFees(data); + + expect(formatEther(swapParams.amountOutMinimum)).toBe(formatEther(quote.amountWithMaxSlippage.value)); + expect(swap.transaction.to).toBe(TEST_SECONDARY_FEE_ADDRESS); // expect the secondary fee contract to be used + }); + }); }); - }); - describe('Pass in zero address', () => { - it('throws InvalidAddressError', async () => { - const params = setupSwapTxTest(); + describe('Swap with single pool without fees and default slippage tolerance', () => { + it('generates valid swap calldata', async () => { + const params = setupSwapTxTest(); - const exchange = new Exchange(TEST_DEX_CONFIGURATION); + mockRouterImplementation(params); - const invalidAddress = constants.AddressZero; + const exchange = new Exchange(TEST_DEX_CONFIGURATION); - await expect( - exchange.getUnsignedSwapTxFromAmountIn( - invalidAddress, + const { swap } = await exchange.getUnsignedSwapTxFromAmountIn( + params.fromAddress, params.inputToken, params.outputToken, newAmountFromString('100', USDC_TEST_TOKEN).value, - HIGHER_SLIPPAGE, - ), - ).rejects.toThrow(new InvalidAddressError('Error: invalid from address')); + ); - await expect( - exchange.getUnsignedSwapTxFromAmountIn( - params.fromAddress, - invalidAddress, - params.outputToken, - newAmountFromString('100', USDC_TEST_TOKEN).value, - HIGHER_SLIPPAGE, - ), - ).rejects.toThrow(new InvalidAddressError('Error: invalid token in address')); + expectToBeDefined(swap.transaction.data); - await expect( - exchange.getUnsignedSwapTxFromAmountIn( - params.fromAddress, - params.inputToken, - invalidAddress, - newAmountFromString('100', USDC_TEST_TOKEN).value, - HIGHER_SLIPPAGE, - ), - ).rejects.toThrow(new InvalidAddressError('Error: invalid token out address')); - }); - }); + const data = swap.transaction.data.toString(); - describe('Pass in invalid addresses', () => { - it('throws InvalidAddressError', async () => { - const params = setupSwapTxTest(); + const { swapParams } = decodeMulticallExactInputSingleWithoutFees(data); + expectInstanceOf(BigNumber, swapParams.amountIn); + + expect(swapParams.tokenIn).toBe(params.inputToken); // input token + expect(swapParams.tokenOut).toBe(params.outputToken); // output token + expect(swapParams.fee).toBe(10000); // fee + expect(swapParams.recipient).toBe(params.fromAddress); // recipient + expect(swap.transaction.to).toBe(TEST_ROUTER_ADDRESS); // to address + expect(swap.transaction.from).toBe(params.fromAddress); // from address + expect(swap.transaction.value).toBe('0x00'); // refers to 0ETH + expect(formatTokenAmount(swapParams.amountIn, USDC_TEST_TOKEN)).toBe('100.0'); // amount in + expect(formatEther(swapParams.amountOutMinimum)).toBe('999.000999000999000999'); // min amount out (includes slippage) + expect(swapParams.sqrtPriceLimitX96.toString()).toBe('0'); // sqrtPriceX96Limit + }); - const exchange = new Exchange(TEST_DEX_CONFIGURATION); + it('returns the gas estimate for the swap', async () => { + const params = setupSwapTxTest(); - const invalidAddress = '0x0123abcdef'; + mockRouterImplementation(params); - await expect( - exchange.getUnsignedSwapTxFromAmountIn( - invalidAddress, - params.inputToken, - params.outputToken, - newAmountFromString('100', USDC_TEST_TOKEN).value, - HIGHER_SLIPPAGE, - ), - ).rejects.toThrow(new InvalidAddressError('Error: invalid from address')); + const exchange = new Exchange(TEST_DEX_CONFIGURATION); - await expect( - exchange.getUnsignedSwapTxFromAmountIn( + const tx = await exchange.getUnsignedSwapTxFromAmountIn( params.fromAddress, - invalidAddress, + params.inputToken, params.outputToken, newAmountFromString('100', USDC_TEST_TOKEN).value, - HIGHER_SLIPPAGE, - ), - ).rejects.toThrow(new InvalidAddressError('Error: invalid token in address')); + ); + + expectToBeDefined(tx.swap.gasFeeEstimate); + + expect(tx.swap.gasFeeEstimate.value).toEqual(TEST_TRANSACTION_GAS_USAGE.mul(TEST_GAS_PRICE)); + expect(tx.swap.gasFeeEstimate.token.chainId).toEqual(NATIVE_TEST_TOKEN.chainId); + expect(tx.swap.gasFeeEstimate.token.address).toEqual(''); // Default configuration is a native token for gas and not an ERC20 + expect(tx.swap.gasFeeEstimate.token.decimals).toEqual(NATIVE_TEST_TOKEN.decimals); + expect(tx.swap.gasFeeEstimate.token.symbol).toEqual(NATIVE_TEST_TOKEN.symbol); + expect(tx.swap.gasFeeEstimate.token.name).toEqual(NATIVE_TEST_TOKEN.name); + }); + + it('returns valid quote', async () => { + const params = setupSwapTxTest(); + + mockRouterImplementation(params); - await expect( - exchange.getUnsignedSwapTxFromAmountIn( + const exchange = new Exchange(TEST_DEX_CONFIGURATION); + + const { quote } = await exchange.getUnsignedSwapTxFromAmountIn( params.fromAddress, params.inputToken, - invalidAddress, + params.outputToken, newAmountFromString('100', USDC_TEST_TOKEN).value, - HIGHER_SLIPPAGE, - ), - ).rejects.toThrow(new InvalidAddressError('Error: invalid token out address')); + ); + + expect(quote).not.toBe(undefined); + expect(quote.amount.token.address).toEqual(params.outputToken); + expect(quote.slippage).toBe(0.1); + expect(formatAmount(quote.amount)).toEqual('1000.0'); + expect(quote.amountWithMaxSlippage.token.address).toEqual(params.outputToken); + expect(formatAmount(quote.amountWithMaxSlippage)).toEqual('999.000999000999000999'); // includes slippage + }); }); - }); - describe('Pass in maxHops > 10', () => { - it('throws InvalidMaxHopsError', async () => { - const params = setupSwapTxTest(); - mockRouterImplementation(params); + describe('Swap with single pool without fees and high slippage tolerance', () => { + it('generates valid calldata', async () => { + const params = setupSwapTxTest(); + mockRouterImplementation(params); - const exchange = new Exchange(TEST_DEX_CONFIGURATION); + const exchange = new Exchange(TEST_DEX_CONFIGURATION); - await expect( - exchange.getUnsignedSwapTxFromAmountIn( + const { swap } = await exchange.getUnsignedSwapTxFromAmountIn( params.fromAddress, params.inputToken, params.outputToken, newAmountFromString('100', USDC_TEST_TOKEN).value, HIGHER_SLIPPAGE, - 11, - ), - ).rejects.toThrow(new InvalidMaxHopsError('Error: max hops must be less than or equal to 10')); - }); - }); + ); - describe('Pass in maxHops < 1', () => { - it('throws InvalidMaxHopsError', async () => { - const params = setupSwapTxTest(); - mockRouterImplementation(params); + expectToBeDefined(swap.transaction.data); - const exchange = new Exchange(TEST_DEX_CONFIGURATION); + const data = swap.transaction.data.toString(); + + const { swapParams } = decodeMulticallExactInputSingleWithoutFees(data); + expectInstanceOf(BigNumber, swapParams.amountIn); + + expect(swapParams.tokenIn).toBe(params.inputToken); // input token + expect(swapParams.tokenOut).toBe(params.outputToken); // output token + expect(swapParams.fee).toBe(10000); // fee + expect(swapParams.recipient).toBe(params.fromAddress); // recipient + expect(swap.transaction.to).toBe(TEST_ROUTER_ADDRESS); // to address + expect(swap.transaction.from).toBe(params.fromAddress); // from address + expect(swap.transaction.value).toBe('0x00'); // refers to 0ETH + expect(formatTokenAmount(swapParams.amountIn, USDC_TEST_TOKEN)).toBe('100.0'); // amount in + expect(formatEther(swapParams.amountOutMinimum)).toBe('998.003992015968063872'); // min amount out (includes 0.2% slippage) + expect(swapParams.sqrtPriceLimitX96.toString()).toBe('0'); // sqrtPriceX96Limit + }); - await expect( - exchange.getUnsignedSwapTxFromAmountIn( + it('returns valid quote', async () => { + const params = setupSwapTxTest(); + mockRouterImplementation(params); + + const exchange = new Exchange(TEST_DEX_CONFIGURATION); + + const { quote } = await exchange.getUnsignedSwapTxFromAmountIn( params.fromAddress, params.inputToken, params.outputToken, newAmountFromString('100', USDC_TEST_TOKEN).value, HIGHER_SLIPPAGE, - 0, - ), - ).rejects.toThrow(new InvalidMaxHopsError('Error: max hops must be greater than or equal to 1')); + ); + + expect(quote.amount.token.address).toEqual(params.outputToken); + expect(quote.slippage).toBe(0.2); + expect(formatAmount(quote.amount)).toEqual('1000.0'); + expect(quote.amountWithMaxSlippage.token.address).toEqual(params.outputToken); + expect(formatAmount(quote.amountWithMaxSlippage)).toEqual('998.003992015968063872'); // includes 0.2% slippage + }); }); - }); - describe('With slippage greater than 50', () => { - it('throws InvalidSlippageError', async () => { - const params = setupSwapTxTest(); - mockRouterImplementation(params); + describe('Pass in zero address', () => { + it('throws InvalidAddressError', async () => { + const params = setupSwapTxTest(); + + const exchange = new Exchange(TEST_DEX_CONFIGURATION); + + const invalidAddress = constants.AddressZero; + + await expect( + exchange.getUnsignedSwapTxFromAmountIn( + invalidAddress, + params.inputToken, + params.outputToken, + newAmountFromString('100', USDC_TEST_TOKEN).value, + HIGHER_SLIPPAGE, + ), + ).rejects.toThrow(new InvalidAddressError('Error: invalid from address')); + + await expect( + exchange.getUnsignedSwapTxFromAmountIn( + params.fromAddress, + invalidAddress, + params.outputToken, + newAmountFromString('100', USDC_TEST_TOKEN).value, + HIGHER_SLIPPAGE, + ), + ).rejects.toThrow(new InvalidAddressError('Error: invalid token in address')); + + await expect( + exchange.getUnsignedSwapTxFromAmountIn( + params.fromAddress, + params.inputToken, + invalidAddress, + newAmountFromString('100', USDC_TEST_TOKEN).value, + HIGHER_SLIPPAGE, + ), + ).rejects.toThrow(new InvalidAddressError('Error: invalid token out address')); + }); + }); - const exchange = new Exchange(TEST_DEX_CONFIGURATION); + describe('Pass in invalid addresses', () => { + it('throws InvalidAddressError', async () => { + const params = setupSwapTxTest(); - await expect( - exchange.getUnsignedSwapTxFromAmountIn( - params.fromAddress, - params.inputToken, - params.outputToken, - newAmountFromString('100', USDC_TEST_TOKEN).value, - 100, - 2, - ), - ).rejects.toThrow(new InvalidSlippageError('Error: slippage percent must be less than or equal to 50')); + const exchange = new Exchange(TEST_DEX_CONFIGURATION); + + const invalidAddress = '0x0123abcdef'; + + await expect( + exchange.getUnsignedSwapTxFromAmountIn( + invalidAddress, + params.inputToken, + params.outputToken, + newAmountFromString('100', USDC_TEST_TOKEN).value, + HIGHER_SLIPPAGE, + ), + ).rejects.toThrow(new InvalidAddressError('Error: invalid from address')); + + await expect( + exchange.getUnsignedSwapTxFromAmountIn( + params.fromAddress, + invalidAddress, + params.outputToken, + newAmountFromString('100', USDC_TEST_TOKEN).value, + HIGHER_SLIPPAGE, + ), + ).rejects.toThrow(new InvalidAddressError('Error: invalid token in address')); + + await expect( + exchange.getUnsignedSwapTxFromAmountIn( + params.fromAddress, + params.inputToken, + invalidAddress, + newAmountFromString('100', USDC_TEST_TOKEN).value, + HIGHER_SLIPPAGE, + ), + ).rejects.toThrow(new InvalidAddressError('Error: invalid token out address')); + }); }); - }); - describe('With slippage less than 0', () => { - it('throws InvalidSlippageError', async () => { - const params = setupSwapTxTest(); - mockRouterImplementation(params); + describe('Pass in maxHops > 10', () => { + it('throws InvalidMaxHopsError', async () => { + const params = setupSwapTxTest(); + mockRouterImplementation(params); - const exchange = new Exchange(TEST_DEX_CONFIGURATION); + const exchange = new Exchange(TEST_DEX_CONFIGURATION); + + await expect( + exchange.getUnsignedSwapTxFromAmountIn( + params.fromAddress, + params.inputToken, + params.outputToken, + newAmountFromString('100', USDC_TEST_TOKEN).value, + HIGHER_SLIPPAGE, + 11, + ), + ).rejects.toThrow(new InvalidMaxHopsError('Error: max hops must be less than or equal to 10')); + }); + }); - await expect( - exchange.getUnsignedSwapTxFromAmountIn( - params.fromAddress, - params.inputToken, - params.outputToken, - newAmountFromString('100', USDC_TEST_TOKEN).value, - -5, - 2, - ), - ).rejects.toThrow(new InvalidSlippageError('Error: slippage percent must be greater than or equal to 0')); + describe('Pass in maxHops < 1', () => { + it('throws InvalidMaxHopsError', async () => { + const params = setupSwapTxTest(); + mockRouterImplementation(params); + + const exchange = new Exchange(TEST_DEX_CONFIGURATION); + + await expect( + exchange.getUnsignedSwapTxFromAmountIn( + params.fromAddress, + params.inputToken, + params.outputToken, + newAmountFromString('100', USDC_TEST_TOKEN).value, + HIGHER_SLIPPAGE, + 0, + ), + ).rejects.toThrow(new InvalidMaxHopsError('Error: max hops must be greater than or equal to 1')); + }); + }); + + describe('With slippage greater than 50', () => { + it('throws InvalidSlippageError', async () => { + const params = setupSwapTxTest(); + mockRouterImplementation(params); + + const exchange = new Exchange(TEST_DEX_CONFIGURATION); + + await expect( + exchange.getUnsignedSwapTxFromAmountIn( + params.fromAddress, + params.inputToken, + params.outputToken, + newAmountFromString('100', USDC_TEST_TOKEN).value, + 100, + 2, + ), + ).rejects.toThrow(new InvalidSlippageError('Error: slippage percent must be less than or equal to 50')); + }); + }); + + describe('With slippage less than 0', () => { + it('throws InvalidSlippageError', async () => { + const params = setupSwapTxTest(); + mockRouterImplementation(params); + + const exchange = new Exchange(TEST_DEX_CONFIGURATION); + + await expect( + exchange.getUnsignedSwapTxFromAmountIn( + params.fromAddress, + params.inputToken, + params.outputToken, + newAmountFromString('100', USDC_TEST_TOKEN).value, + -5, + 2, + ), + ).rejects.toThrow(new InvalidSlippageError('Error: slippage percent must be greater than or equal to 0')); + }); }); }); }); diff --git a/packages/internal/dex/sdk/src/exchange.getUnsignedSwapTxFromAmountOut.test.ts b/packages/internal/dex/sdk/src/exchange.getUnsignedSwapTxFromAmountOut.test.ts index adf35b12d0..6e50c74ce6 100644 --- a/packages/internal/dex/sdk/src/exchange.getUnsignedSwapTxFromAmountOut.test.ts +++ b/packages/internal/dex/sdk/src/exchange.getUnsignedSwapTxFromAmountOut.test.ts @@ -35,6 +35,7 @@ import { WIMX_TEST_TOKEN, expectToBeString, refundETHFunctionSignature, + NATIVE_TEST_TOKEN, } from './test/utils'; jest.mock('@ethersproject/providers'); @@ -366,6 +367,136 @@ describe('getUnsignedSwapTxFromAmountOut', () => { expect(decodedRefundEthTx.length).toEqual(0); // expect that the refundETH call has no parameters }); }); + + describe('when the output token is native', () => { + it('should not include any amount as the value of the transaction', async () => { + mockRouterImplementation({ + pools: [createPool(nativeTokenService.wrappedToken, FUN_TEST_TOKEN)], + }); + + const exchange = new Exchange(TEST_DEX_CONFIGURATION); + + const { swap } = await exchange.getUnsignedSwapTxFromAmountOut( + TEST_FROM_ADDRESS, + FUN_TEST_TOKEN.address, + 'native', + newAmountFromString('100', NATIVE_TEST_TOKEN).value, + ); + + expectToBeDefined(swap.transaction.data); + expectToBeDefined(swap.transaction.value); + const data = swap.transaction.data.toString(); + + const { swapParams } = decodeMulticallExactOutputSingleWithoutFees(data); + expectInstanceOf(BigNumber, swapParams.amountOut); + + expect(swapParams.tokenIn).toBe(FUN_TEST_TOKEN.address); // should be the token-in + expect(swapParams.tokenOut).toBe(WIMX_TEST_TOKEN.address); // should be the wrapped native token + expect(swap.transaction.value).toBe('0x00'); // should not have a value + }); + + it('should include a call to unwrapWETH9 as the final method call of the calldata', async () => { + mockRouterImplementation({ + pools: [createPool(nativeTokenService.wrappedToken, FUN_TEST_TOKEN)], + }); + + const swapRouterInterface = SwapRouter.INTERFACE; + const paymentsInterface = PaymentsExtended.INTERFACE; + const exchange = new Exchange(TEST_DEX_CONFIGURATION); + + // Buy 100 native tokens for X amount of FUN where the exchange rate is 1 token-in : 10 token-out + const { swap } = await exchange.getUnsignedSwapTxFromAmountOut( + TEST_FROM_ADDRESS, + FUN_TEST_TOKEN.address, + 'native', + newAmountFromString('100', NATIVE_TEST_TOKEN).value, + 3, // 3 % slippage + ); + + expectToBeDefined(swap.transaction.data); + expectToBeDefined(swap.transaction.value); + const calldata = swap.transaction.data.toString(); + + const topLevelParams = swapRouterInterface.decodeFunctionData('multicall(uint256,bytes[])', calldata); + + expect(topLevelParams.data.length).toBe(2); // expect that there are two calls in the multicall + const swapFunctionCalldata = topLevelParams.data[0]; + const unwrapWETHFunctionCalldata = topLevelParams.data[1]; + + expectToBeString(swapFunctionCalldata); + expectToBeString(unwrapWETHFunctionCalldata); + + // Get the first 4 bytes of the swap and unwrap function calldata to get the function selector + const swapFunctionFragment = swapRouterInterface.getFunction(swapFunctionCalldata.slice(0, 10)); + const unwrapFunctionFragment = paymentsInterface.getFunction(unwrapWETHFunctionCalldata.slice(0, 10)); + + expect(swapFunctionFragment.name).toEqual('exactOutputSingle'); + expect(unwrapFunctionFragment.name).toEqual('unwrapWETH9'); + }); + + it('should specify the Router contract as the recipient of the swap function call', async () => { + mockRouterImplementation({ + pools: [createPool(nativeTokenService.wrappedToken, FUN_TEST_TOKEN)], + }); + + const exchange = new Exchange(TEST_DEX_CONFIGURATION); + + // Buy 100 native tokens for X amount of FUN where the exchange rate is 1 token-in : 10 token-out + const { swap } = await exchange.getUnsignedSwapTxFromAmountOut( + TEST_FROM_ADDRESS, + FUN_TEST_TOKEN.address, + 'native', + newAmountFromString('100', NATIVE_TEST_TOKEN).value, + 3, // 3 % slippage + ); + + expectToBeDefined(swap.transaction.data); + expectToBeDefined(swap.transaction.value); + + const { swapParams } = decodeMulticallExactOutputSingleWithoutFees(swap.transaction.data); + + expect(swapParams.recipient).toEqual(TEST_ROUTER_ADDRESS); + }); + + it('should specify the quoted amount with slippage applied in the unwrapWETH9 function calldata', async () => { + mockRouterImplementation({ + pools: [createPool(nativeTokenService.wrappedToken, FUN_TEST_TOKEN)], + }); + + const swapRouterInterface = SwapRouter.INTERFACE; + const paymentsInterface = PaymentsExtended.INTERFACE; + const exchange = new Exchange(TEST_DEX_CONFIGURATION); + + // Buy 100 native tokens for X amount of FUN where the exchange rate is 1 token-in : 10 token-out + const { swap } = await exchange.getUnsignedSwapTxFromAmountOut( + TEST_FROM_ADDRESS, + FUN_TEST_TOKEN.address, + 'native', + newAmountFromString('100', NATIVE_TEST_TOKEN).value, + 10, // 10 % slippage for easier test math + ); + + expectToBeDefined(swap.transaction.data); + expectToBeDefined(swap.transaction.value); + const calldata = swap.transaction.data.toString(); + + const topLevelParams = swapRouterInterface.decodeFunctionData('multicall(uint256,bytes[])', calldata); + + expect(topLevelParams.data.length).toBe(2); // expect that there are two calls in the multicall + const swapFunctionCalldata = topLevelParams.data[0]; + const unwrapWETHFunctionCalldata = topLevelParams.data[1]; + + expectToBeString(swapFunctionCalldata); + expectToBeString(unwrapWETHFunctionCalldata); + + const decodedUnwrapWETH9FunctionData = paymentsInterface.decodeFunctionData( + 'unwrapWETH9(uint256)', + unwrapWETHFunctionCalldata, + ); + + expect(formatEther(decodedUnwrapWETH9FunctionData.toString())).toEqual('100.0'); // expect the user-specified amount + }); + }); }); describe('Swap with multiple pools and secondary fees', () => { diff --git a/packages/internal/dex/sdk/src/exchange.ts b/packages/internal/dex/sdk/src/exchange.ts index 972efe6ac6..6632f78ed9 100644 --- a/packages/internal/dex/sdk/src/exchange.ts +++ b/packages/internal/dex/sdk/src/exchange.ts @@ -169,6 +169,7 @@ export class Exchange { const swap = getSwap( tokenIn, + tokenOut, adjustedQuote, fromAddress, slippagePercent, diff --git a/packages/internal/dex/sdk/src/lib/transactionUtils/swap.test.ts b/packages/internal/dex/sdk/src/lib/transactionUtils/swap.test.ts index a3aaa18b8a..71d831831e 100644 --- a/packages/internal/dex/sdk/src/lib/transactionUtils/swap.test.ts +++ b/packages/internal/dex/sdk/src/lib/transactionUtils/swap.test.ts @@ -72,6 +72,7 @@ describe('getSwap', () => { const swap = getSwap( quote.amountIn.token, + quote.amountOut.token, quote, makeAddr('fromAddress'), slippagePercentage, @@ -95,6 +96,7 @@ describe('getSwap', () => { const swap = getSwap( quote.amountIn.token, + quote.amountOut.token, quote, makeAddr('fromAddress'), slippagePercentage, @@ -120,6 +122,7 @@ describe('getSwap', () => { const swap = getSwap( quote.amountIn.token, + quote.amountOut.token, quote, makeAddr('fromAddress'), slippagePercentage, @@ -143,6 +146,7 @@ describe('getSwap', () => { const swap = getSwap( quote.amountIn.token, + quote.amountOut.token, quote, makeAddr('fromAddress'), slippagePercentage, @@ -164,11 +168,13 @@ describe('getSwap', () => { describe('with EXACT_INPUT + native amount in', () => { it('uses the amountSpecified as the transaction value', () => { const originalTokenIn = nativeTokenService.nativeToken; + const originalTokenOut = FUN_TEST_TOKEN; const quote = buildExactInputQuote(nativeTokenService.wrappedToken, FUN_TEST_TOKEN); quote.amountIn.value = utils.parseEther('99'); const swap = getSwap( originalTokenIn, + originalTokenOut, quote, makeAddr('fromAddress'), slippagePercentage, @@ -186,10 +192,12 @@ describe('getSwap', () => { describe('with EXACT_INPUT + native amount out', () => { it('sets a transaction value of zero', () => { const originalTokenIn = FUN_TEST_TOKEN; + const originalTokenOut = NATIVE_TEST_TOKEN; const quote = buildExactInputQuote(FUN_TEST_TOKEN, nativeTokenService.wrappedToken); const swap = getSwap( originalTokenIn, + originalTokenOut, quote, makeAddr('fromAddress'), slippagePercentage, @@ -207,11 +215,13 @@ describe('getSwap', () => { describe('with EXACT_OUTPUT + native amount in', () => { it('sets the transaction value to the max amount in including slippage', () => { const originalTokenIn = nativeTokenService.nativeToken; + const originalTokenOut = FUN_TEST_TOKEN; const quote = buildExactOutputQuote(nativeTokenService.wrappedToken, FUN_TEST_TOKEN); quote.amountIn.value = utils.parseEther('100'); const swap = getSwap( originalTokenIn, + originalTokenOut, quote, makeAddr('fromAddress'), slippagePercentage, @@ -229,10 +239,12 @@ describe('getSwap', () => { describe('with EXACT_OUTPUT + native amount out', () => { it('sets a transaction value of zero', () => { const originalTokenIn = FUN_TEST_TOKEN; + const originalTokenOut = NATIVE_TEST_TOKEN; const quote = buildExactOutputQuote(FUN_TEST_TOKEN, nativeTokenService.wrappedToken); const swap = getSwap( originalTokenIn, + originalTokenOut, quote, makeAddr('fromAddress'), slippagePercentage, diff --git a/packages/internal/dex/sdk/src/lib/transactionUtils/swap.ts b/packages/internal/dex/sdk/src/lib/transactionUtils/swap.ts index e97b0eaa06..24d74f0258 100644 --- a/packages/internal/dex/sdk/src/lib/transactionUtils/swap.ts +++ b/packages/internal/dex/sdk/src/lib/transactionUtils/swap.ts @@ -4,7 +4,7 @@ import * as Uniswap from '@uniswap/sdk-core'; import { SecondaryFee__factory } from 'contracts/types'; import { ISecondaryFee, SecondaryFeeInterface } from 'contracts/types/SecondaryFee'; import { Fees } from 'lib/fees'; -import { toCurrencyAmount, toPublicAmount } from 'lib/utils'; +import { isNative, toCurrencyAmount, toPublicAmount } from 'lib/utils'; import { QuoteResult } from 'lib/getQuotesForRoutes'; import { NativeTokenService, canUnwrapToken } from 'lib/nativeTokenService'; import { Coin, CoinAmount } from 'types'; @@ -24,7 +24,8 @@ const multicallWithDeadlineFunctionSignature = 'multicall(uint256,bytes[])'; function buildSinglePoolSwap( tokenIn: Coin, - fromAddress: string, + tokenOut: Coin, + recipient: string, trade: Trade, route: Route, amountIn: string, @@ -41,41 +42,48 @@ function buildSinglePoolSwap( tokenIn: route.tokenPath[0].address, tokenOut: route.tokenPath[1].address, fee: route.pools[0].fee, - recipient: fromAddress, + recipient, amountIn, amountOutMinimum: amountOut, sqrtPriceLimitX96: 0, }, ]), ); - - return calldatas; } - calldatas.push( - routerContract.encodeFunctionData('exactOutputSingle', [ - { - tokenIn: route.tokenPath[0].address, - tokenOut: route.tokenPath[1].address, - fee: route.pools[0].fee, - recipient: fromAddress, - amountInMaximum: amountIn, - amountOut, - sqrtPriceLimitX96: 0, - }, - ]), - ); + if (trade.tradeType === Uniswap.TradeType.EXACT_OUTPUT) { + calldatas.push( + routerContract.encodeFunctionData('exactOutputSingle', [ + { + tokenIn: route.tokenPath[0].address, + tokenOut: route.tokenPath[1].address, + fee: route.pools[0].fee, + recipient, + amountInMaximum: amountIn, + amountOut, + sqrtPriceLimitX96: 0, + }, + ]), + ); + } - if (tokenIn.type === 'native') { + const shouldRefundNativeTokens = trade.tradeType === Uniswap.TradeType.EXACT_OUTPUT && isNative(tokenIn); + if (shouldRefundNativeTokens) { // Refund ETH if the input token is native and the swap is exact output calldatas.push(paymentsContract.encodeFunctionData('refundETH')); } + const shouldUnwrapTokens = isNative(tokenOut); + if (shouldUnwrapTokens) { + // Unwrap the output token if the user specified a native token as the output + calldatas.push(paymentsContract.encodeFunctionData('unwrapWETH9(uint256)', [amountOut])); + } + return calldatas; } function buildSinglePoolSwapWithFees( - fromAddress: string, + recipient: string, trade: Trade, route: Route, amountIn: string, @@ -98,31 +106,31 @@ function buildSinglePoolSwapWithFees( tokenIn: route.tokenPath[0].address, tokenOut: route.tokenPath[1].address, fee: route.pools[0].fee, - recipient: fromAddress, + recipient, amountIn, amountOutMinimum: amountOut, sqrtPriceLimitX96: 0, }, ]), ); - - return calldatas; } - calldatas.push( - secondaryFeeContract.encodeFunctionData('exactOutputSingleWithSecondaryFee', [ - secondaryFeeValues, - { - tokenIn: route.tokenPath[0].address, - tokenOut: route.tokenPath[1].address, - fee: route.pools[0].fee, - recipient: fromAddress, - amountInMaximum: amountIn, - amountOut, - sqrtPriceLimitX96: 0, - }, - ]), - ); + if (trade.tradeType === Uniswap.TradeType.EXACT_OUTPUT) { + calldatas.push( + secondaryFeeContract.encodeFunctionData('exactOutputSingleWithSecondaryFee', [ + secondaryFeeValues, + { + tokenIn: route.tokenPath[0].address, + tokenOut: route.tokenPath[1].address, + fee: route.pools[0].fee, + recipient, + amountInMaximum: amountIn, + amountOut, + sqrtPriceLimitX96: 0, + }, + ]), + ); + } // TODO: Add refundETH method when support is added in SecondaryFee contract @@ -131,7 +139,8 @@ function buildSinglePoolSwapWithFees( function buildMultiPoolSwap( tokenIn: Coin, - fromAddress: string, + tokenOut: Coin, + recipient: string, trade: Trade, route: Route, amountIn: string, @@ -147,37 +156,44 @@ function buildMultiPoolSwap( routerContract.encodeFunctionData('exactInput', [ { path, - recipient: fromAddress, + recipient, amountIn, amountOutMinimum: amountOut, }, ]), ); - - return calldatas; } - calldatas.push( - routerContract.encodeFunctionData('exactOutput', [ - { - path, - recipient: fromAddress, - amountInMaximum: amountIn, - amountOut, - }, - ]), - ); + if (trade.tradeType === Uniswap.TradeType.EXACT_OUTPUT) { + calldatas.push( + routerContract.encodeFunctionData('exactOutput', [ + { + path, + recipient, + amountInMaximum: amountIn, + amountOut, + }, + ]), + ); + } - if (tokenIn.type === 'native') { + const shouldRefundNativeTokens = trade.tradeType === Uniswap.TradeType.EXACT_OUTPUT && isNative(tokenIn); + if (shouldRefundNativeTokens) { // Refund ETH if the input token is native and the swap is exact output calldatas.push(paymentsContract.encodeFunctionData('refundETH')); } + const shouldUnwrapTokens = isNative(tokenOut); + if (shouldUnwrapTokens) { + // Unwrap the output token if the user specified a native token as the output + calldatas.push(paymentsContract.encodeFunctionData('unwrapWETH9(uint256)', [amountOut])); + } + return calldatas; } function buildMultiPoolSwapWithFees( - fromAddress: string, + recipient: string, trade: Trade, route: Route, amountIn: string, @@ -200,27 +216,27 @@ function buildMultiPoolSwapWithFees( secondaryFeeValues, { path, - recipient: fromAddress, + recipient, amountIn, amountOutMinimum: amountOut, }, ]), ); - - return calldatas; } - calldatas.push( - secondaryFeeContract.encodeFunctionData('exactOutputWithSecondaryFee', [ - secondaryFeeValues, - { - path, - recipient: fromAddress, - amountInMaximum: amountIn, - amountOut, - }, - ]), - ); + if (trade.tradeType === Uniswap.TradeType.EXACT_OUTPUT) { + calldatas.push( + secondaryFeeContract.encodeFunctionData('exactOutputWithSecondaryFee', [ + secondaryFeeValues, + { + path, + recipient, + amountInMaximum: amountIn, + amountOut, + }, + ]), + ); + } // TODO: Add refundETH method when support is added in SecondaryFee contract @@ -230,6 +246,7 @@ function buildMultiPoolSwapWithFees( /** * Builds and array of calldatas for the swap to be executed in the multicall method * @param tokenIn The token to be swapped + * @param tokenOut The token to be received * @param fromAddress The address of the user * @param trade The trade to be executed * @param secondaryFees Secondary fees to be applied to the swap @@ -242,7 +259,8 @@ function buildMultiPoolSwapWithFees( */ function buildSwapParameters( tokenIn: Coin, - fromAddress: string, + tokenOut: Coin, + recipient: string, trade: Trade, secondaryFees: SecondaryFee[], secondaryFeeContract: SecondaryFeeInterface, @@ -261,7 +279,7 @@ function buildSwapParameters( if (isSinglePoolSwap) { if (hasSecondaryFees) { return buildSinglePoolSwapWithFees( - fromAddress, + recipient, trade, route, maximumAmountIn, @@ -273,7 +291,8 @@ function buildSwapParameters( return buildSinglePoolSwap( tokenIn, - fromAddress, + tokenOut, + recipient, trade, route, maximumAmountIn, @@ -285,7 +304,7 @@ function buildSwapParameters( if (hasSecondaryFees) { return buildMultiPoolSwapWithFees( - fromAddress, + recipient, trade, route, maximumAmountIn, @@ -297,7 +316,8 @@ function buildSwapParameters( return buildMultiPoolSwap( tokenIn, - fromAddress, + tokenOut, + recipient, trade, route, maximumAmountIn, @@ -309,8 +329,9 @@ function buildSwapParameters( function createSwapCallParameters( tokenIn: Coin, + tokenOut: Coin, trade: Trade, - fromAddress: string, + recipient: string, swapOptions: SwapOptions, secondaryFees: SecondaryFee[], maximumAmountIn: string, @@ -322,7 +343,8 @@ function createSwapCallParameters( const calldatas = buildSwapParameters( tokenIn, - fromAddress, + tokenOut, + recipient, trade, secondaryFees, secondaryFeeContract, @@ -341,6 +363,7 @@ function createSwapCallParameters( function createSwapParameters( tokenIn: Coin, + tokenOut: Coin, adjustedQuote: QuoteResult, fromAddress: string, slippage: number, @@ -372,6 +395,7 @@ function createSwapParameters( return { calldata: createSwapCallParameters( tokenIn, + tokenOut, uncheckedTrade, fromAddress, options, @@ -388,19 +412,23 @@ const getTransactionValue = (tokenIn: Coin, maximumAmountIn: string) => export function getSwap( tokenIn: Coin, + tokenOut: Coin, adjustedQuote: QuoteResult, fromAddress: string, slippage: number, deadline: number, - peripheryRouterAddress: string, - secondaryFeesAddress: string, + routerContractAddress: string, + secondaryFeesContractAddress: string, gasPrice: CoinAmount | null, secondaryFees: SecondaryFee[], ): TransactionDetails { + const swapRecipient = isNative(tokenOut) ? routerContractAddress : fromAddress; + const { calldata, maximumAmountIn } = createSwapParameters( tokenIn, + tokenOut, adjustedQuote, - fromAddress, + swapRecipient, slippage, deadline, secondaryFees, @@ -414,7 +442,7 @@ export function getSwap( return { transaction: { data: calldata, - to: secondaryFees.length > 0 ? secondaryFeesAddress : peripheryRouterAddress, + to: secondaryFees.length > 0 ? secondaryFeesContractAddress : routerContractAddress, value: transactionValue, from: fromAddress, }, diff --git a/packages/internal/dex/sdk/src/lib/utils.ts b/packages/internal/dex/sdk/src/lib/utils.ts index bd16bb330f..cd0e2a6930 100644 --- a/packages/internal/dex/sdk/src/lib/utils.ts +++ b/packages/internal/dex/sdk/src/lib/utils.ts @@ -106,6 +106,8 @@ export const isERC20Amount = (amount: CoinAmount): amount is CoinAmount): amount is CoinAmount => amount.token.type === 'native'; +export const isNative = (token: Coin): token is Native => token.type === 'native'; + export const addERC20Amount = (a: CoinAmount, b: CoinAmount) => { // Make sure the ERC20s have the same address if (a.token.address !== b.token.address) throw new Error('Token mismatch: token addresses must be the same'); diff --git a/packages/internal/dex/sdk/src/test/utils.ts b/packages/internal/dex/sdk/src/test/utils.ts index ebd024a157..b8ff193a18 100644 --- a/packages/internal/dex/sdk/src/test/utils.ts +++ b/packages/internal/dex/sdk/src/test/utils.ts @@ -164,7 +164,7 @@ type SecondaryFeeFunctionName = | 'exactInputWithSecondaryFee' | 'exactOutputWithSecondaryFee'; -type SwapRouterFunctionName = 'exactInputSingle' | 'exactOutputSingle'; +type SwapRouterFunctionName = 'exactInputSingle' | 'exactOutputSingle' | 'exactInput' | 'exactOutput'; function decodeSecondaryFeeCall(calldata: utils.BytesLike, functionName: SecondaryFeeFunctionName) { const iface = SecondaryFee__factory.createInterface(); @@ -202,6 +202,19 @@ export function decodeMulticallExactInputWithFees(data: utils.BytesLike) { return { secondaryFeeParams, swapParams }; } +export function decodeMulticallExactInputWithoutFees(data: utils.BytesLike) { + const decodedParams = decodeSwapRouterCall(data, 'exactInput'); + + const swapParams: IV3SwapRouter.ExactInputParamsStruct = { + path: decodedParams[0][0], + recipient: decodedParams[0][1], + amountIn: decodedParams[0][2], + amountOutMinimum: decodedParams[0][3], + }; + + return { swapParams }; +} + export function decodeMulticallExactOutputWithFees(data: utils.BytesLike) { const decodedParams = decodeSecondaryFeeCall(data, 'exactOutputWithSecondaryFee');