diff --git a/sdks/v4-sdk/src/utils/v4BaseActionsParser.test.ts b/sdks/v4-sdk/src/utils/v4BaseActionsParser.test.ts index b97f80aab..ab173ded6 100644 --- a/sdks/v4-sdk/src/utils/v4BaseActionsParser.test.ts +++ b/sdks/v4-sdk/src/utils/v4BaseActionsParser.test.ts @@ -207,6 +207,7 @@ describe('Command Parser', () => { { currencyIn: DAI.address, path: encodeRouteToPath(new Route([DAI_USDC, USDC_WETH], DAI, WETH9[1])), + maxHopSlippage: [], amountIn: amount, amountOutMinimum: amount, }, @@ -237,6 +238,7 @@ describe('Command Parser', () => { hookData: '0x', }, ], + maxHopSlippage: [], amountIn: amount, amountOutMinimum: amount, }, @@ -251,6 +253,7 @@ describe('Command Parser', () => { { currencyOut: DAI.address, path: encodeRouteToPath(new Route([DAI_USDC, USDC_WETH], DAI, WETH9[1])), + maxHopSlippage: [], amountOut: amount, amountInMaximum: amount, }, @@ -281,6 +284,7 @@ describe('Command Parser', () => { hookData: '0x', }, ], + maxHopSlippage: [], amountOut: amount, amountInMaximum: amount, }, diff --git a/sdks/v4-sdk/src/utils/v4BaseActionsParser.ts b/sdks/v4-sdk/src/utils/v4BaseActionsParser.ts index caa3d7619..91fdd1921 100644 --- a/sdks/v4-sdk/src/utils/v4BaseActionsParser.ts +++ b/sdks/v4-sdk/src/utils/v4BaseActionsParser.ts @@ -29,6 +29,7 @@ export type SwapExactInSingle = { export type SwapExactIn = { readonly currencyIn: string readonly path: readonly PathKey[] + readonly maxHopSlippage: readonly string[] readonly amountIn: string readonly amountOutMinimum: string } @@ -44,6 +45,7 @@ export type SwapExactOutSingle = { export type SwapExactOut = { readonly currencyOut: string readonly path: readonly PathKey[] + readonly maxHopSlippage: readonly string[] readonly amountOut: string readonly amountInMaximum: string } @@ -162,12 +164,13 @@ function parseV4ExactInSingle(data: any[]): SwapExactInSingle { } function parseV4ExactIn(data: any[]): SwapExactIn { - const [currencyIn, path, amountIn, amountOutMinimum] = data + const [currencyIn, path, maxHopSlippage, amountIn, amountOutMinimum] = data const paths: readonly PathKey[] = path.map((pathKey: string) => parsePathKey(pathKey)) return { path: paths, currencyIn, + maxHopSlippage, amountIn, amountOutMinimum, } @@ -193,12 +196,13 @@ function parseV4ExactOutSingle(data: any[]): SwapExactOutSingle { } function parseV4ExactOut(data: any[]): SwapExactOut { - const [currencyOut, path, amountOut, amountInMaximum] = data + const [currencyOut, path, maxHopSlippage, amountOut, amountInMaximum] = data const paths: readonly PathKey[] = path.map((pathKey: string) => parsePathKey(pathKey)) return { path: paths, currencyOut, + maxHopSlippage, amountOut, amountInMaximum, } diff --git a/sdks/v4-sdk/src/utils/v4Planner.test.ts b/sdks/v4-sdk/src/utils/v4Planner.test.ts index d467415ed..399a8f98d 100644 --- a/sdks/v4-sdk/src/utils/v4Planner.test.ts +++ b/sdks/v4-sdk/src/utils/v4Planner.test.ts @@ -1,4 +1,4 @@ -import { BigNumber } from 'ethers' +import { BigNumber, utils } from 'ethers' import JSBI from 'jsbi' import { CurrencyAmount, Ether, Percent, TradeType, Token, WETH9 } from '@uniswap/sdk-core' import { encodeSqrtRatioX96, nearestUsableTick, TickMath } from '@uniswap/v3-sdk' @@ -8,7 +8,9 @@ import { Route } from '../entities/route' import { encodeRouteToPath } from './encodeRouteToPath' import { ADDRESS_ZERO, FEE_AMOUNT_MEDIUM, TICK_SPACING_TEN, ONE_ETHER, NEGATIVE_ONE } from '../internalConstants' -import { Actions, V4Planner } from './v4Planner' +import { Actions, V4Planner, V4_BASE_ACTIONS_ABI_DEFINITION } from './v4Planner' + +const { defaultAbiCoder } = utils const ONE_ETHER_BN = BigNumber.from(1).mul(10).pow(18) const TICKLIST = [ @@ -96,6 +98,7 @@ describe('RouterPlanner', () => { { currencyIn: DAI.address, path: encodeRouteToPath(route), + maxHopSlippage: [], amountIn: ONE_ETHER_BN.toString(), amountOutMinimum: 0, }, @@ -112,7 +115,7 @@ describe('RouterPlanner', () => { expect(planner.actions).toEqual('0x07') expect(planner.params[0]).toEqual( - '0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006b175474e89094c44da98b954eedeac495271d0f00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000100000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000' + '0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006b175474e89094c44da98b954eedeac495271d0f00000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000002800000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000100000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000' ) expect(planner.actions).toEqual(tradePlanner.actions) @@ -131,7 +134,7 @@ describe('RouterPlanner', () => { expect(planner.actions).toEqual('0x09') expect(planner.params[0]).toEqual( - '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc200000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001000000000000000000000000006b175474e89094c44da98b954eedeac495271d0f0000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000' + '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc200000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000002800000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001000000000000000000000000006b175474e89094c44da98b954eedeac495271d0f0000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000' ) }) @@ -147,7 +150,7 @@ describe('RouterPlanner', () => { expect(planner.actions).toEqual('0x09') expect(planner.params[0]).toEqual( - '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc200000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001000000000000000000000000006b175474e89094c44da98b954eedeac495271d0f0000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000' + '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc200000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000002800000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001000000000000000000000000006b175474e89094c44da98b954eedeac495271d0f0000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000' ) }) @@ -163,7 +166,7 @@ describe('RouterPlanner', () => { expect(planner.actions).toEqual('0x07') expect(planner.params[0]).toEqual( - '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc200000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000100000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006b175474e89094c44da98b954eedeac495271d0f0000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000' + '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc200000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000002800000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000100000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006b175474e89094c44da98b954eedeac495271d0f0000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000' ) }) @@ -193,6 +196,112 @@ describe('RouterPlanner', () => { 'Only accepts Trades with 1 swap (must break swaps into individual trades)' ) }) + + it('completes a v4 exactIn 2 hop swap with per-hop slippage limits', async () => { + const route = new Route([DAI_USDC, USDC_WETH], DAI, WETH9[1]) + const trade = await Trade.fromRoute( + route, + CurrencyAmount.fromRawAmount(DAI, ONE_ETHER.toString()), + TradeType.EXACT_INPUT + ) + + // Set per-hop slippage limits: 10000 for first hop, 20000 for second hop + const maxHopSlippage = [BigNumber.from('10000'), BigNumber.from('20000')] + + planner.addTrade(trade, undefined, maxHopSlippage) + + expect(planner.actions).toEqual('0x07') + + // Decode the params to verify the maxHopSlippage values + const decoded = defaultAbiCoder.decode( + V4_BASE_ACTIONS_ABI_DEFINITION[Actions.SWAP_EXACT_IN].map((v) => v.type), + planner.params[0] + ) + + expect(decoded[0].currencyIn).toEqual(DAI.address) + expect(decoded[0].maxHopSlippage).toHaveLength(2) + expect(decoded[0].maxHopSlippage[0].toString()).toEqual('10000') + expect(decoded[0].maxHopSlippage[1].toString()).toEqual('20000') + }) + + it('completes a v4 exactOut 2 hop swap with per-hop slippage limits', async () => { + const route = new Route([DAI_USDC, USDC_WETH], DAI, WETH9[1]) + const slippageTolerance = new Percent('5') + const trade = await Trade.fromRoute( + route, + CurrencyAmount.fromRawAmount(WETH9[1], ONE_ETHER.toString()), + TradeType.EXACT_OUTPUT + ) + + // Set per-hop slippage limits: 10000 for first hop, 20000 for second hop + const maxHopSlippage = [BigNumber.from('10000'), BigNumber.from('20000')] + + planner.addTrade(trade, slippageTolerance, maxHopSlippage) + + expect(planner.actions).toEqual('0x09') + + // Decode the params to verify the maxHopSlippage values + const decoded = defaultAbiCoder.decode( + V4_BASE_ACTIONS_ABI_DEFINITION[Actions.SWAP_EXACT_OUT].map((v) => v.type), + planner.params[0] + ) + + expect(decoded[0].currencyOut).toEqual(WETH9[1].address) + expect(decoded[0].maxHopSlippage).toHaveLength(2) + expect(decoded[0].maxHopSlippage[0].toString()).toEqual('10000') + expect(decoded[0].maxHopSlippage[1].toString()).toEqual('20000') + }) + + it('completes a v4 exactIn 2 hop swap using addAction with per-hop slippage limits', async () => { + const route = new Route([DAI_USDC, USDC_WETH], DAI, WETH9[1]) + const maxHopSlippage = [BigNumber.from('10000').toString(), BigNumber.from('20000').toString()] + + planner.addAction(Actions.SWAP_EXACT_IN, [ + { + currencyIn: DAI.address, + path: encodeRouteToPath(route), + maxHopSlippage: maxHopSlippage, + amountIn: ONE_ETHER_BN.toString(), + amountOutMinimum: 0, + }, + ]) + + expect(planner.actions).toEqual('0x07') + + // Decode the params to verify the maxHopSlippage values + const decoded = defaultAbiCoder.decode( + V4_BASE_ACTIONS_ABI_DEFINITION[Actions.SWAP_EXACT_IN].map((v) => v.type), + planner.params[0] + ) + + expect(decoded[0].currencyIn).toEqual(DAI.address) + expect(decoded[0].maxHopSlippage).toHaveLength(2) + expect(decoded[0].maxHopSlippage[0].toString()).toEqual('10000') + expect(decoded[0].maxHopSlippage[1].toString()).toEqual('20000') + expect(decoded[0].amountIn.toString()).toEqual(ONE_ETHER_BN.toString()) + }) + + it('completes a v4 exactIn swap with empty maxHopSlippage when not provided', async () => { + const route = new Route([DAI_USDC, USDC_WETH], DAI, WETH9[1]) + const trade = await Trade.fromRoute( + route, + CurrencyAmount.fromRawAmount(DAI, ONE_ETHER.toString()), + TradeType.EXACT_INPUT + ) + + planner.addTrade(trade) + + expect(planner.actions).toEqual('0x07') + + // Decode the params to verify the maxHopSlippage is empty array + const decoded = defaultAbiCoder.decode( + V4_BASE_ACTIONS_ABI_DEFINITION[Actions.SWAP_EXACT_IN].map((v) => v.type), + planner.params[0] + ) + + expect(decoded[0].currencyIn).toEqual(DAI.address) + expect(decoded[0].maxHopSlippage).toHaveLength(0) + }) }) describe('addSettle', () => { diff --git a/sdks/v4-sdk/src/utils/v4Planner.ts b/sdks/v4-sdk/src/utils/v4Planner.ts index e3fa98b5a..6f286c537 100644 --- a/sdks/v4-sdk/src/utils/v4Planner.ts +++ b/sdks/v4-sdk/src/utils/v4Planner.ts @@ -71,13 +71,17 @@ const SWAP_EXACT_IN_SINGLE_STRUCT = '(' + POOL_KEY_STRUCT + ' poolKey,bool zeroForOne,uint128 amountIn,uint128 amountOutMinimum,bytes hookData)' const SWAP_EXACT_IN_STRUCT = - '(address currencyIn,' + PATH_KEY_STRUCT + '[] path,uint128 amountIn,uint128 amountOutMinimum)' + '(address currencyIn,' + + PATH_KEY_STRUCT + + '[] path,uint256[] maxHopSlippage,uint128 amountIn,uint128 amountOutMinimum)' const SWAP_EXACT_OUT_SINGLE_STRUCT = '(' + POOL_KEY_STRUCT + ' poolKey,bool zeroForOne,uint128 amountOut,uint128 amountInMaximum,bytes hookData)' const SWAP_EXACT_OUT_STRUCT = - '(address currencyOut,' + PATH_KEY_STRUCT + '[] path,uint128 amountOut,uint128 amountInMaximum)' + '(address currencyOut,' + + PATH_KEY_STRUCT + + '[] path,uint256[] maxHopSlippage,uint128 amountOut,uint128 amountInMaximum)' // eslint-disable-next-line @typescript-eslint/no-unused-vars export const V4_BASE_ACTIONS_ABI_DEFINITION: { [key in Actions]: readonly ParamType[] } = { @@ -182,7 +186,11 @@ export class V4Planner { return this } - addTrade(trade: Trade, slippageTolerance?: Percent): V4Planner { + addTrade( + trade: Trade, + slippageTolerance?: Percent, + maxHopSlippage?: BigNumber[] + ): V4Planner { const exactOutput = trade.tradeType === TradeType.EXACT_OUTPUT // exactInput we sometimes perform aggregated slippage checks, but not with exactOutput @@ -194,17 +202,22 @@ export class V4Planner { const currencyIn = currencyAddress(trade.route.pathInput) const currencyOut = currencyAddress(trade.route.pathOutput) + // If no per-hop slippage limits provided, use empty array (no per-hop checks) + const maxHopSlippageArray = maxHopSlippage ?? [] + this.addAction(actionType, [ exactOutput ? { currencyOut, path: encodeRouteToPath(trade.route, exactOutput), + maxHopSlippage: maxHopSlippageArray, amountInMaximum: trade.maximumAmountIn(slippageTolerance ?? new Percent(0)).quotient.toString(), amountOut: trade.outputAmount.quotient.toString(), } : { currencyIn, path: encodeRouteToPath(trade.route, exactOutput), + maxHopSlippage: maxHopSlippageArray, amountIn: trade.inputAmount.quotient.toString(), amountOutMinimum: slippageTolerance ? trade.minimumAmountOut(slippageTolerance).quotient.toString() : 0, },