From 984b0f14edb48923f175a5d42f0c98f86da52c0e Mon Sep 17 00:00:00 2001 From: Emily Williams Date: Thu, 19 Sep 2024 20:42:31 -0400 Subject: [PATCH] fix(v4-sdk): V4 planner accommodates exactOut + granular take/settles (#97) --- sdks/v4-sdk/src/utils/v4Planner.test.ts | 226 ++++++++++++++++++------ sdks/v4-sdk/src/utils/v4Planner.ts | 55 ++++-- 2 files changed, 211 insertions(+), 70 deletions(-) diff --git a/sdks/v4-sdk/src/utils/v4Planner.test.ts b/sdks/v4-sdk/src/utils/v4Planner.test.ts index 1e21c17b1..a78afc164 100644 --- a/sdks/v4-sdk/src/utils/v4Planner.test.ts +++ b/sdks/v4-sdk/src/utils/v4Planner.test.ts @@ -1,6 +1,6 @@ import { BigNumber } from 'ethers' import JSBI from 'jsbi' -import { CurrencyAmount, TradeType, Token, WETH9 } from '@uniswap/sdk-core' +import { CurrencyAmount, Percent, TradeType, Token, WETH9 } from '@uniswap/sdk-core' import { encodeSqrtRatioX96, nearestUsableTick, TickMath } from '@uniswap/v3-sdk' import { Pool } from '../entities/pool' import { Trade } from '../entities/trade' @@ -48,6 +48,17 @@ const DAI_USDC = new Pool( 0, TICKLIST ) +const DAI_WETH = new Pool( + WETH9[1], + DAI, + FEE_AMOUNT_MEDIUM, + TICK_SPACING_TEN, + ADDRESS_ZERO, + encodeSqrtRatioX96(1, 1), + 0, + 0, + TICKLIST +) describe('RouterPlanner', () => { let planner: V4Planner @@ -56,61 +67,166 @@ describe('RouterPlanner', () => { planner = new V4Planner() }) - it('encodes a v4 exactInSingle swap', async () => { - planner.addAction(Actions.SWAP_EXACT_IN_SINGLE, [ - { - poolKey: USDC_WETH.poolKey, - zeroForOne: true, - amountIn: ONE_ETHER_BN, - amountOutMinimum: ONE_ETHER_BN.div(2), - sqrtPriceLimitX96: 0, - hookData: '0x', - }, - ]) - planner.addAction(Actions.SETTLE_TAKE_PAIR, [USDC.address, WETH9[1].address]) - - expect(planner.actions).toEqual('0x0416') - expect(planner.params[0]).toEqual( - '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000006f05b59d3b20000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000000' - ) - expect(planner.params[1]).toEqual( - '0x000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2' - ) + describe('addAction', () => { + it('encodes a v4 exactInSingle swap', async () => { + planner.addAction(Actions.SWAP_EXACT_IN_SINGLE, [ + { + poolKey: USDC_WETH.poolKey, + zeroForOne: true, + amountIn: ONE_ETHER_BN, + amountOutMinimum: ONE_ETHER_BN.div(2), + sqrtPriceLimitX96: 0, + hookData: '0x', + }, + ]) + + expect(planner.actions).toEqual('0x04') + expect(planner.params[0]).toEqual( + '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000006f05b59d3b20000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000000' + ) + }) }) - it('completes a v4 exactIn 2 hop swap', async () => { - const route = new Route([DAI_USDC, USDC_WETH], DAI, WETH9[1]) - - // encode with addAction function - planner.addAction(Actions.SWAP_EXACT_IN, [ - { - currencyIn: DAI.address, - path: encodeRouteToPath(route), - amountIn: ONE_ETHER_BN.toString(), - amountOutMinimum: 0, - }, - ]) - planner.addAction(Actions.SETTLE_TAKE_PAIR, [DAI.address, WETH9[1].address]) - - // encode with addTrade function - const tradePlanner = new V4Planner() - const trade = await Trade.fromRoute( - route, - CurrencyAmount.fromRawAmount(DAI, ONE_ETHER.toString()), - TradeType.EXACT_INPUT - ) - tradePlanner.addTrade(trade) - - expect(planner.actions).toEqual('0x0516') - expect(planner.params[0]).toEqual( - '0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006b175474e89094c44da98b954eedeac495271d0f00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000100000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000' - ) - expect(planner.params[1]).toEqual( - '0x0000000000000000000000006b175474e89094c44da98b954eedeac495271d0f000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2' - ) - - expect(planner.actions).toEqual(tradePlanner.actions) - expect(planner.params[0]).toEqual(tradePlanner.params[0]) - expect(planner.params[1]).toEqual(tradePlanner.params[1]) + describe('addTrade', () => { + it('completes a v4 exactIn 2 hop swap with same results as same addAction', async () => { + const route = new Route([DAI_USDC, USDC_WETH], DAI, WETH9[1]) + + // encode with addAction function + planner.addAction(Actions.SWAP_EXACT_IN, [ + { + currencyIn: DAI.address, + path: encodeRouteToPath(route), + amountIn: ONE_ETHER_BN.toString(), + amountOutMinimum: 0, + }, + ]) + + // encode with addTrade function + const tradePlanner = new V4Planner() + const trade = await Trade.fromRoute( + route, + CurrencyAmount.fromRawAmount(DAI, ONE_ETHER.toString()), + TradeType.EXACT_INPUT + ) + tradePlanner.addTrade(trade) + + expect(planner.actions).toEqual('0x05') + expect(planner.params[0]).toEqual( + '0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006b175474e89094c44da98b954eedeac495271d0f00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000100000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000' + ) + + expect(planner.actions).toEqual(tradePlanner.actions) + expect(planner.params[0]).toEqual(tradePlanner.params[0]) + }) + + it('completes a v4 exactOut 2 hop swap', 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 + ) + planner.addTrade(trade, slippageTolerance) + + expect(planner.actions).toEqual('0x07') + expect(planner.params[0]).toEqual( + '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001000000000000000000000000006b175474e89094c44da98b954eedeac495271d0f0000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000' + ) + }) + + it('throws an error if adding exactOut trade without slippage tolerance', async () => { + const route = new Route([DAI_USDC, USDC_WETH], DAI, WETH9[1]) + const trade = await Trade.fromRoute( + route, + CurrencyAmount.fromRawAmount(WETH9[1], ONE_ETHER.toString()), + TradeType.EXACT_OUTPUT + ) + expect(() => planner.addTrade(trade)).toThrow('ExactOut requires slippageTolerance') + }) + + it('throws an error if adding exactOut trade without slippage tolerance', async () => { + const slippageTolerance = new Percent('5') + const amount = CurrencyAmount.fromRawAmount(WETH9[1], ONE_ETHER.toString()) + const route1 = new Route([DAI_USDC, USDC_WETH], DAI, WETH9[1]) + const route2 = new Route([DAI_WETH], DAI, WETH9[1]) + const trade = await Trade.fromRoutes( + [ + { route: route1, amount }, + { route: route2, amount }, + ], + TradeType.EXACT_OUTPUT + ) + expect(() => planner.addTrade(trade, slippageTolerance)).toThrow( + 'Only accepts Trades with 1 swap (must break swaps into individual trades)' + ) + }) + }) + + describe('addSettle', () => { + it('completes a settle without a specified amount', async () => { + const payerIsUser = true + planner.addSettle(DAI, payerIsUser) + + expect(planner.actions).toEqual('0x09') + expect(planner.params[0]).toEqual( + '0x0000000000000000000000006b175474e89094c44da98b954eedeac495271d0f00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001' + ) + }) + + it('completes a settle with a specified amount', async () => { + const payerIsUser = true + const amount = BigNumber.from('8') + planner.addSettle(DAI, payerIsUser, amount) + + expect(planner.actions).toEqual('0x09') + expect(planner.params[0]).toEqual( + '0x0000000000000000000000006b175474e89094c44da98b954eedeac495271d0f00000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000001' + ) + }) + + it('completes a settle with payerIsUser as false', async () => { + const payerIsUser = false + const amount = BigNumber.from('8') + planner.addSettle(DAI, payerIsUser, amount) + + expect(planner.actions).toEqual('0x09') + expect(planner.params[0]).toEqual( + '0x0000000000000000000000006b175474e89094c44da98b954eedeac495271d0f00000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000' + ) + }) + }) + + describe('addTrade', () => { + it('completes a take without a specified amount', async () => { + const routerMustCustody = true + planner.addTake(DAI, routerMustCustody) + + expect(planner.actions).toEqual('0x12') + expect(planner.params[0]).toEqual( + '0x0000000000000000000000006b175474e89094c44da98b954eedeac495271d0f00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000' + ) + }) + + it('completes a take with a specified amount', async () => { + const routerMustCustody = true + const amount = BigNumber.from('8') + planner.addTake(DAI, routerMustCustody, amount) + + expect(planner.actions).toEqual('0x12') + expect(planner.params[0]).toEqual( + '0x0000000000000000000000006b175474e89094c44da98b954eedeac495271d0f00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000008' + ) + }) + + it('completes a take when router will not custody', async () => { + const routerMustCustody = false + planner.addTake(DAI, routerMustCustody) + + expect(planner.actions).toEqual('0x12') + expect(planner.params[0]).toEqual( + '0x0000000000000000000000006b175474e89094c44da98b954eedeac495271d0f00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000' + ) + }) }) }) diff --git a/sdks/v4-sdk/src/utils/v4Planner.ts b/sdks/v4-sdk/src/utils/v4Planner.ts index 474182d33..44d90fc91 100644 --- a/sdks/v4-sdk/src/utils/v4Planner.ts +++ b/sdks/v4-sdk/src/utils/v4Planner.ts @@ -1,4 +1,6 @@ +import invariant from 'tiny-invariant' import { defaultAbiCoder } from 'ethers/lib/utils' +import { BigNumber } from 'ethers' import { Currency, Percent, TradeType } from '@uniswap/sdk-core' import { Trade } from '../entities/trade' import { ADDRESS_ZERO } from '../internalConstants' @@ -91,9 +93,9 @@ const ABI_DEFINITION: { [key in Actions]: string[] } = { [Actions.SWAP_EXACT_OUT]: [SWAP_EXACT_OUT_STRUCT], // Payments commands - [Actions.SETTLE]: ['address', 'uint256', 'bool'], + [Actions.SETTLE]: ['address', 'uint256', 'bool'], // currency, amount, payerIsUser [Actions.SETTLE_ALL]: ['address', 'uint256'], - [Actions.TAKE]: ['address', 'address', 'uint256'], + [Actions.TAKE]: ['address', 'address', 'uint256'], // currency, receiver, amount [Actions.TAKE_ALL]: ['address', 'uint256'], [Actions.TAKE_PORTION]: ['address', 'address', 'uint256'], [Actions.SETTLE_TAKE_PAIR]: ['address', 'address'], @@ -101,6 +103,10 @@ const ABI_DEFINITION: { [key in Actions]: string[] } = { [Actions.SWEEP]: ['address', 'address'], } +const FULL_DELTA_AMOUNT = 0 +const MSG_SENDER = '0x0000000000000000000000000000000000000001' +const ADDRESS_THIS = '0x0000000000000000000000000000000000000002' + export class V4Planner { actions: string params: string[] @@ -117,23 +123,42 @@ export class V4Planner { } addTrade(trade: Trade, slippageTolerance?: Percent): void { - const actionType = trade.tradeType === TradeType.EXACT_INPUT ? Actions.SWAP_EXACT_IN : Actions.SWAP_EXACT_OUT + const exactOutput = trade.tradeType === TradeType.EXACT_OUTPUT + + // exactInput we sometimes perform aggregated slippage checks, but not with exactOutput + if (exactOutput) invariant(!!slippageTolerance, 'ExactOut requires slippageTolerance') + invariant(trade.swaps.length === 1, 'Only accepts Trades with 1 swap (must break swaps into individual trades)') + + const actionType = exactOutput ? Actions.SWAP_EXACT_OUT : Actions.SWAP_EXACT_IN const currencyIn = currencyAddress(trade.inputAmount.currency) const currencyOut = currencyAddress(trade.outputAmount.currency) - for (let swap of trade.swaps) { - this.addAction(actionType, [ - { - currencyIn, - path: encodeRouteToPath(swap.route), - amountIn: swap.inputAmount.quotient.toString(), - amountOutMinimum: slippageTolerance ? trade.minimumAmountOut(slippageTolerance).quotient.toString() : 0, - }, - ]) - } - - this.addAction(Actions.SETTLE_TAKE_PAIR, [currencyIn, currencyOut]) + this.addAction(actionType, [ + exactOutput + ? { + currencyOut, + path: encodeRouteToPath(trade.route, exactOutput), + amountInMaximum: trade.maximumAmountIn(slippageTolerance ?? new Percent(0)).quotient.toString(), + amountOut: trade.inputAmount.quotient.toString(), + } + : { + currencyIn, + path: encodeRouteToPath(trade.route, exactOutput), + amountIn: trade.inputAmount.quotient.toString(), + amountOutMinimum: slippageTolerance ? trade.minimumAmountOut(slippageTolerance).quotient.toString() : 0, + }, + ]) + } + + addSettle(currency: Currency, payerIsUser: boolean, amount?: BigNumber): void { + this.addAction(Actions.SETTLE, [currencyAddress(currency), amount ?? FULL_DELTA_AMOUNT, payerIsUser]) + } + + addTake(currency: Currency, routerMustCustody: boolean, amount?: BigNumber): void { + const receiver = routerMustCustody ? ADDRESS_THIS : MSG_SENDER + const takeAmount = amount ?? FULL_DELTA_AMOUNT + this.addAction(Actions.TAKE, [currencyAddress(currency), receiver, takeAmount]) } finalize(): string {