diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index 4bcd9c9..86baf3b 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -290,7 +290,9 @@ const V4_SWAP_EXACT_IN = 0x07; const V4_SWAP_EXACT_OUT_SINGLE = 0x08; const V4_SWAP_EXACT_OUT = 0x09; const V4_SETTLE = 0x0b; +const V4_SETTLE_ALL = 0x0c; const V4_TAKE = 0x0e; +const V4_TAKE_ALL = 0x0f; // A V4 exact-in `amountIn`, read the way V4Router reads it: zero is // ActionConstants.OPEN_DELTA, not a quantity of zero. See the OPEN_DELTA @@ -318,10 +320,102 @@ function decodeExactly(types, data) { return decoded; } +// What decodeV4Swap() knows of a swap action it could not read, and of the +// first and last swap action of a step that has none. +const NO_SWAP_READ = { + tokenIn: null, + tokenOut: null, + amountIn: null, + amountOutMin: null, +}; + +// Decode one V4 swap action's parameters into the currency it spends +// (`tokenIn`), the currency it buys (`tokenOut`) and, for an exact-in action, +// its `amountIn` and `amountOutMin`. The exact-out actions decode no amounts. +// A field the action does not state is null. Throws when the parameters are +// not in the deployed shape (see decodeExactly()). +function decodeV4SwapAction(actionId, param) { + if (actionId === V4_SWAP_EXACT_IN) { + // ExactInputParams: (address currencyIn, + // tuple(address,uint24,int24,address,bytes)[] path, + // uint128 amountIn, uint128 amountOutMin) + const s = decodeExactly( + [ + "tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)", + ], + param, + ); + const path = s[0][1]; + return { + tokenIn: s[0][0], + tokenOut: path.length > 0 ? path[path.length - 1][0] : null, + amountIn: v4ExactInAmount(s[0][2]), + amountOutMin: s[0][3], + }; + } + if (actionId === V4_SWAP_EXACT_IN_SINGLE) { + // ExactInputSingleParams: (tuple(address,address,uint24,int24,address) poolKey, + // bool zeroForOne, uint128 amountIn, uint128 amountOutMin, bytes hookData) + const s = decodeExactly( + [ + "tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)", + ], + param, + ); + const poolKey = s[0][0]; + const zeroForOne = s[0][1]; + return { + tokenIn: zeroForOne ? poolKey[0] : poolKey[1], + tokenOut: zeroForOne ? poolKey[1] : poolKey[0], + amountIn: v4ExactInAmount(s[0][2]), + amountOutMin: s[0][3], + }; + } + if (actionId === V4_SWAP_EXACT_OUT) { + // ExactOutputParams: (address currencyOut, + // tuple(address,uint24,int24,address,bytes)[] path, + // uint128 amountOut, uint128 amountInMaximum) + const s = decodeExactly( + [ + "tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)", + ], + param, + ); + const path = s[0][1]; + return { + tokenIn: path.length > 0 ? path[path.length - 1][0] : null, + tokenOut: s[0][0], + amountIn: null, + amountOutMin: null, + }; + } + // V4_SWAP_EXACT_OUT_SINGLE. ExactOutputSingleParams: (poolKey as above, + // bool zeroForOne, uint128 amountOut, uint128 amountInMaximum, + // bytes hookData) + const s = decodeExactly( + [ + "tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)", + ], + param, + ); + const poolKey = s[0][0]; + const zeroForOne = s[0][1]; + return { + tokenIn: zeroForOne ? poolKey[0] : poolKey[1], + tokenOut: zeroForOne ? poolKey[1] : poolKey[0], + amountIn: null, + amountOutMin: null, + }; +} + // Decode V4_SWAP (command 0x10) input bytes. // The input is ABI-encoded as (bytes actions, bytes[] params). -// We extract token addresses from SETTLE (input) and TAKE (output) sub-actions, -// and swap amounts from the swap sub-actions. +// SETTLE or SETTLE_ALL names the step's input currency, and TAKE or TAKE_ALL +// its output currency, wherever in the step they stand. A side neither one +// names is read from the swap actions. In a chain of swap actions each one +// spends what the one before it bought, so the input is what the first swap +// action spends and the output is what the last one buys. Amount comes from +// the first swap action and Min. received from the last. function decodeV4Swap(input) { try { const d = coder.decode(["bytes", "bytes[]"], input); @@ -330,11 +424,12 @@ function decodeV4Swap(input) { let settleToken = null; let takeToken = null; - let amountIn = null; - let amountOutMin = null; + // The step's swap actions in order, as decodeV4SwapAction() reads + // them. + const swaps = []; // Set when a swap action's parameters are not in the deployed shape - // (see decodeExactly()), so nothing was read from that action. SETTLE - // and TAKE may still name both currencies. + // (see decodeExactly()), so nothing was read from that action. SETTLE, + // SETTLE_ALL, TAKE and TAKE_ALL may still name both currencies. let swapUndecoded = false; for (let i = 0; i < actions.length; i++) { @@ -347,6 +442,10 @@ function decodeV4Swap(input) { params[i], ); settleToken = s[0]; + } else if (actionId === V4_SETTLE_ALL) { + // SETTLE_ALL: (address currency, uint256 maxAmount) + const s = coder.decode(["address", "uint256"], params[i]); + settleToken = s[0]; } else if (actionId === V4_TAKE) { // TAKE: (address currency, address recipient, uint256 amount) const t = coder.decode( @@ -354,107 +453,21 @@ function decodeV4Swap(input) { params[i], ); takeToken = t[0]; + } else if (actionId === V4_TAKE_ALL) { + // TAKE_ALL: (address currency, uint256 minAmount) + const t = coder.decode(["address", "uint256"], params[i]); + takeToken = t[0]; } else if ( + actionId === V4_SWAP_EXACT_IN_SINGLE || actionId === V4_SWAP_EXACT_IN || - actionId === V4_SWAP_EXACT_IN_SINGLE + actionId === V4_SWAP_EXACT_OUT_SINGLE || + actionId === V4_SWAP_EXACT_OUT ) { - // Extract amounts from exact-in swap actions - if (actionId === V4_SWAP_EXACT_IN) { - // ExactInputParams: (address currencyIn, - // tuple(address,uint24,int24,address,bytes)[] path, - // uint128 amountIn, uint128 amountOutMin) - try { - const s = decodeExactly( - [ - "tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)", - ], - params[i], - ); - if (!present(settleToken)) settleToken = s[0][0]; - const path = s[0][1]; - if (path.length > 0 && !present(takeToken)) { - takeToken = path[path.length - 1][0]; - } - if (!present(amountIn)) { - amountIn = v4ExactInAmount(s[0][2]); - } - if (!present(amountOutMin)) { - amountOutMin = s[0][3]; - } - } catch { - swapUndecoded = true; - } - } else { - // ExactInputSingleParams: (tuple(address,address,uint24,int24,address) poolKey, - // bool zeroForOne, uint128 amountIn, uint128 amountOutMin, bytes hookData) - try { - const s = decodeExactly( - [ - "tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)", - ], - params[i], - ); - const poolKey = s[0][0]; - const zeroForOne = s[0][1]; - if (!present(settleToken)) - settleToken = zeroForOne - ? poolKey[0] - : poolKey[1]; - if (!present(takeToken)) - takeToken = zeroForOne - ? poolKey[1] - : poolKey[0]; - if (!present(amountIn)) { - amountIn = v4ExactInAmount(s[0][2]); - } - if (!present(amountOutMin)) { - amountOutMin = s[0][3]; - } - } catch { - swapUndecoded = true; - } - } - } else if ( - actionId === V4_SWAP_EXACT_OUT || - actionId === V4_SWAP_EXACT_OUT_SINGLE - ) { - if (actionId === V4_SWAP_EXACT_OUT) { - try { - const s = decodeExactly( - [ - "tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)", - ], - params[i], - ); - if (!present(takeToken)) takeToken = s[0][0]; - const path = s[0][1]; - if (path.length > 0 && !present(settleToken)) { - settleToken = path[path.length - 1][0]; - } - } catch { - swapUndecoded = true; - } - } else { - try { - const s = decodeExactly( - [ - "tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)", - ], - params[i], - ); - const poolKey = s[0][0]; - const zeroForOne = s[0][1]; - if (!present(settleToken)) - settleToken = zeroForOne - ? poolKey[0] - : poolKey[1]; - if (!present(takeToken)) - takeToken = zeroForOne - ? poolKey[1] - : poolKey[0]; - } catch { - swapUndecoded = true; - } + try { + swaps.push(decodeV4SwapAction(actionId, params[i])); + } catch { + swapUndecoded = true; + swaps.push(NO_SWAP_READ); } } } catch { @@ -462,11 +475,13 @@ function decodeV4Swap(input) { } } + const first = swaps.length > 0 ? swaps[0] : NO_SWAP_READ; + const last = swaps.length > 0 ? swaps[swaps.length - 1] : NO_SWAP_READ; return { - tokenIn: settleToken, - tokenOut: takeToken, - amountIn, - amountOutMin, + tokenIn: present(settleToken) ? settleToken : first.tokenIn, + tokenOut: present(takeToken) ? takeToken : last.tokenOut, + amountIn: first.amountIn, + amountOutMin: last.amountOutMin, swapUndecoded, }; } catch { diff --git a/tests/uniswap.test.js b/tests/uniswap.test.js index 8fa6606..360cba6 100644 --- a/tests/uniswap.test.js +++ b/tests/uniswap.test.js @@ -267,9 +267,7 @@ function v4SwapCalldata(actionId, swapParams) { ); } -// V4 SETTLE_ALL (currency, maxAmount) and TAKE_ALL (currency, minAmount). The -// decoder reads neither, so in a V4_SWAP of a swap action and these, only the -// swap action names the currencies. +// V4 SETTLE_ALL (currency, maxAmount) and TAKE_ALL (currency, minAmount). const V4_SETTLE_ALL_ID = 0x0c; const V4_TAKE_ALL_ID = 0x0f; function encodeV4SettleAll(currency, maxAmount) { @@ -358,10 +356,9 @@ describe("uniswap decoder", () => { }, ); - // Only the unread swap action names the currencies here. The token lines - // must not say the calldata did not name them. + // SETTLE_ALL and TAKE_ALL name both sides as SETTLE and TAKE do. test.each(V4_SWAP_ACTION_SHAPES)( - "a V4 $action in the newer shape, then SETTLE_ALL and TAKE_ALL, says both tokens could not be decoded", + "a V4 $action in the newer shape, then SETTLE_ALL and TAKE_ALL, takes both tokens from them", ({ id, newer }) => { const data = buildExecute( "0x10", @@ -381,6 +378,27 @@ describe("uniswap decoder", () => { expect(detail(result, "Steps").value).toBe( "V4 Swap (could not be decoded)", ); + expect(detail(result, "Token In").address).toBe(WETH_ADDR); + expect(detail(result, "Token Out").address).toBe(USDC_ADDR); + expect(detail(result, "Amount")).toBeUndefined(); + expect(detail(result, "Min. received")).toBeUndefined(); + }, + ); + + // Only the unread swap action names the currencies here. The token lines + // must not say the calldata did not name them. + test.each(V4_SWAP_ACTION_SHAPES)( + "a V4 step of only a $action in the newer shape says both tokens could not be decoded", + ({ id, newer }) => { + const data = buildExecute( + "0x10", + [encodeV4Swap(new Uint8Array([id]), [newer])], + 9999999999n, + ); + const result = uniswap.decode(data, ROUTER_ADDR); + expect(detail(result, "Steps").value).toBe( + "V4 Swap (could not be decoded)", + ); expect(detail(result, "Token In").value).toBe( "Unknown (could not be decoded)", ); @@ -394,7 +412,7 @@ describe("uniswap decoder", () => { // action names. The V3 step's WETH and its Min. received figure were // shown instead. test.each(V4_SWAP_ACTION_SHAPES)( - "a V3 step, then a V4 $action in the newer shape and TAKE_ALL, does not show the V3 step's output", + "a V3 step, then a V4 step of only a $action in the newer shape, does not show the V3 step's output", ({ id, newer }) => { const data = buildExecute( solidityPacked(["uint8", "uint8"], [0x00, 0x10]), @@ -403,10 +421,7 @@ describe("uniswap decoder", () => { USDT_ADDR, WETH_ADDR, ]), - encodeV4Swap(new Uint8Array([id, V4_TAKE_ALL_ID]), [ - newer, - encodeV4TakeAll(USDC_ADDR, 990000n), - ]), + encodeV4Swap(new Uint8Array([id]), [newer]), ], 9999999999n, ); @@ -423,15 +438,12 @@ describe("uniswap decoder", () => { // The user pays the V4 step's WETH, which only its unread swap action // names. The V3 step's USDC and its amount were shown instead. test.each(V4_SWAP_ACTION_SHAPES)( - "a V4 $action in the newer shape and SETTLE_ALL, then a V3 step, does not show the V3 step's input", + "a V4 step of only a $action in the newer shape, then a V3 step, does not show the V3 step's input", ({ id, newer }) => { const data = buildExecute( solidityPacked(["uint8", "uint8"], [0x10, 0x00]), [ - encodeV4Swap(new Uint8Array([id, V4_SETTLE_ALL_ID]), [ - newer, - encodeV4SettleAll(WETH_ADDR, ONE_WETH), - ]), + encodeV4Swap(new Uint8Array([id]), [newer]), encodeV3SwapExactIn( USER_ADDR, 990000n, @@ -458,10 +470,7 @@ describe("uniswap decoder", () => { const data = buildExecute( solidityPacked(["uint8", "uint8"], [0x10, 0x0c]), [ - encodeV4Swap(new Uint8Array([id, V4_TAKE_ALL_ID]), [ - newer, - encodeV4TakeAll(USDC_ADDR, 990000n), - ]), + encodeV4Swap(new Uint8Array([id]), [newer]), encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH, same encoding ], 9999999999n, @@ -1124,6 +1133,74 @@ describe("uniswap decoder", () => { expect(detail(result, "Min. received").value).toBe("0.9900 USDC"); }); + // https://git.eeqj.de/sneak/AutistMask/issues/563 — one V4 step of two + // chained single-pool exact-in swap actions, USDT to WETH and then WETH to + // USDC. The user pays USDT and receives USDC, but the screen showed the + // first action's WETH as Token Out and its minimum as Min. received. + test.each([ + { + ending: "SETTLE_ALL and TAKE_ALL", + ids: [V4_SETTLE_ALL_ID, V4_TAKE_ALL_ID], + params: [ + encodeV4SettleAll(USDT_ADDR, 1000000000n), + encodeV4TakeAll(USDC_ADDR, 990000000n), + ], + }, + { + ending: "SETTLE and TAKE", + ids: [V4_SETTLE_ID, V4_TAKE_ID], + params: [encodeV4Settle(USDT_ADDR), encodeV4Take(USDC_ADDR)], + }, + { + // Only the swap actions name the currencies. + ending: "nothing", + ids: [], + params: [], + }, + ])( + "two chained V4 swap actions followed by $ending show the first one's input and the last one's output", + ({ ids, params }) => { + const data = buildExecute( + "0x10", + [ + encodeV4Swap( + new Uint8Array([ + V4_SWAP_EXACT_IN_SINGLE_ID, + V4_SWAP_EXACT_IN_SINGLE_ID, + ...ids, + ]), + [ + // 1,000 USDT for at least 0.4 WETH + encodeV4ExactInSingle( + USDT_ADDR, + WETH_ADDR, + 1000000000n, + 400000000000000000n, + ), + // all of that WETH (the open delta) for at least + // 990 USDC + encodeV4ExactInSingle( + WETH_ADDR, + USDC_ADDR, + 0n, + 990000000n, + ), + ...params, + ], + ), + ], + 9999999999n, + ); + + const result = uniswap.decode(data, ROUTER_ADDR); + expect(result.name).toBe("Swap USDT → USDC"); + expect(detail(result, "Token In").address).toBe(USDT_ADDR); + expect(detail(result, "Amount").value).toBe("1000.0000 USDT"); + expect(detail(result, "Token Out").address).toBe(USDC_ADDR); + expect(detail(result, "Min. received").value).toBe("990.0000 USDC"); + }, + ); + // https://git.eeqj.de/sneak/AutistMask/issues/415 — the router's V2 // exact-in reads an amountIn of zero as universal-router // Constants.ALREADY_PAID: an earlier step sent the tokens to the pair, and