From f4f2c549f5896b9d5331b702c752092dd6ec9824 Mon Sep 17 00:00:00 2001 From: sneak Date: Sun, 18 Oct 2026 18:40:37 +0000 Subject: [PATCH] fix: a V4 step of chained swap actions shows what the user receives (closes #563) A V4 step's input side is now the currency its SETTLE or SETTLE_ALL names, and its output side the currency its TAKE or TAKE_ALL names. A side none of them names comes from the swap actions, put in the order the currencies pass through them: each exact-in action after the ones before it, each exact-out action in front of them, since the router runs exact-out actions from the output end. The input and Amount come from the first action in that order, the output and Min. received from the last. SETTLE_ALL and TAKE_ALL were not read before, and the first swap action set both sides, so a chain showed the first one's output. Model: opus-5-5 --- README.md | 39 ++++--- src/shared/uniswap.js | 251 ++++++++++++++++++++++------------------ tests/uniswap.test.js | 259 ++++++++++++++++++++++++++++++++++++++---- 3 files changed, 405 insertions(+), 144 deletions(-) diff --git a/README.md b/README.md index c5e6a35..ec35443 100644 --- a/README.md +++ b/README.md @@ -1164,8 +1164,9 @@ read: amount of one step that names an input token or amount: the last `PERMIT2_PERMIT` step if there is one, otherwise the first V2 or V3 exact-in, V2 exact-out, `WRAP_ETH` or V4 swap step. A V2 exact-out step gives its - `amountInMax`, and a V4 swap step the `amountIn` of its first readable - exact-in action. + `amountInMax`, and a V4 swap step the `amountIn` of the swap action that + spends what the user pays (see below), or none when that action is exact-out + or could not be read. - `Whatever an earlier step sent to the pair (V2 already paid)`: the swap's `Amount` line, when the amount it shows is a V2 exact-in `amountIn` of zero. The router reads that zero as "the pair already holds the input tokens": the @@ -1186,17 +1187,29 @@ read: The swap's `Token In` and `Token Out` lines name a currency, not an amount; each reads `Unknown (not named in the calldata)` when the decoder found no token for that side, or `Unknown (could not be decoded)` instead when a V4 swap action in -the transaction could not be read (see Transaction Decoding below). A V4 step -whose swap action could not be read still sets each side as a swap step does, -and a side its `SETTLE` or `TAKE` did not name is then unknown: no other step's -token or figure is shown for it. An `UNWRAP_WETH` step makes `Token Out` ETH -only when the output side is WETH, on mainnet or Sepolia, or when no step set -the output side; a WETH `Min. received` figure then reads in ETH. Otherwise -`Token Out` and `Min. received` keep the output side's own token and figure, -whether the swap was paid in ETH or in a token: a V2 exact-out swap that buys -USDC and then unwraps the WETH it did not spend shows USDC. The token permission -warning on the signature screen has its own amount wording, including `Unknown`; -the SignApproval section below describes it. +the transaction could not be read (see Transaction Decoding below). A V4 step's +input side is the currency its `SETTLE` or `SETTLE_ALL` names, and its output +side the currency its `TAKE` or `TAKE_ALL` names. A side none of them names +comes from the step's swap actions, put in the order the currencies pass through +them, from what the user pays to what the user receives. The router runs +exact-in actions from the input end, so each one goes after the actions before +it, whose output it spends. It runs exact-out actions from the output end, so +the first one buys what the user receives and each later one goes in front of +the actions before it, buying what they spend. The input side and `Amount` come +from the first action in that order, and the output side and `Min. received` +from the last; the decoder reads no amount from an exact-out action. A swap +action that could not be read keeps its place in that order and states nothing, +so a side or figure that would come from it is unknown, while one from a +readable action is shown. Such a step still sets each side as a swap step does, +and no other step's token or figure is shown for an unknown one. An +`UNWRAP_WETH` step makes `Token Out` ETH only when the output side is WETH, on +mainnet or Sepolia, or when no step set the output side; a WETH `Min. received` +figure then reads in ETH. Otherwise `Token Out` and `Min. received` keep the +output side's own token and figure, whether the swap was paid in ETH or in a +token: a V2 exact-out swap that buys USDC and then unwraps the WETH it did not +spend shows USDC. The token permission warning on the signature screen has its +own amount wording, including `Unknown`; the SignApproval section below +describes it. The swap's `Deadline` line is the router's deadline as a UTC date and time, e.g. `2026-02-27 08:25:51`. A JavaScript date reaches only to 275760-09-13 00:00:00 diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index 4bcd9c9..5919e47 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,107 @@ 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, put in the order the currencies pass +// through them, from what the user pays to what the user receives. The router +// runs exact-in actions from the input end: each one spends what the actions +// before it bought, so it goes after them. It runs exact-out actions from the +// output end: the first one buys what the user receives, and each later one +// buys what the actions before it spend, so it goes in front of them. The +// input and Amount come from the first action in that order, and the output +// and Min. received from the last. A swap action that could not be read keeps +// its place and states nothing. function decodeV4Swap(input) { try { const d = coder.decode(["bytes", "bytes[]"], input); @@ -330,11 +429,12 @@ function decodeV4Swap(input) { let settleToken = null; let takeToken = null; - let amountIn = null; - let amountOutMin = null; + // The step's swap actions, as decodeV4SwapAction() reads them, from + // what the user pays to what the user receives. + 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 +447,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 +458,29 @@ 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; - } + let swap = NO_SWAP_READ; + try { + swap = decodeV4SwapAction(actionId, params[i]); + } 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; - } + if ( + actionId === V4_SWAP_EXACT_OUT_SINGLE || + actionId === V4_SWAP_EXACT_OUT + ) { + swaps.unshift(swap); } 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; - } + swaps.push(swap); } } } catch { @@ -462,11 +488,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 { @@ -597,11 +625,12 @@ function decode(data, toAddress, sources) { if (cmdId === 0x10) { const v4 = decodeV4Swap(inputs[i]); if (v4 && v4.swapUndecoded) { - // The unread swap action named this step's currencies - // and amounts, so the step is marked and still sets - // both sides as a swap step does. A side its SETTLE or - // TAKE did not name is set undetermined, not left to - // an earlier or later step's token and figure. + // A swap action of this step went unread, so the step + // is marked and still sets both sides as a swap step + // does. A currency or figure that neither SETTLE, + // SETTLE_ALL, TAKE, TAKE_ALL nor a readable swap + // action gave decodeV4Swap() is set undetermined, not + // left to an earlier or later step's token and figure. swapUndecoded = true; commandNames[i] += " (could not be decoded)"; if (!inputEstablished) { diff --git a/tests/uniswap.test.js b/tests/uniswap.test.js index 8fa6606..49f1159 100644 --- a/tests/uniswap.test.js +++ b/tests/uniswap.test.js @@ -107,6 +107,7 @@ function encodeV4Swap(actions, params) { // V4 inner action IDs, as src/shared/uniswap.js names them. const V4_SWAP_EXACT_IN = 0x07; const V4_SWAP_EXACT_IN_SINGLE_ID = 0x06; +const V4_SWAP_EXACT_OUT_SINGLE_ID = 0x08; const V4_SETTLE_ID = 0x0b; const V4_TAKE_ID = 0x0e; const ZERO_ADDR = "0x0000000000000000000000000000000000000000"; @@ -163,6 +164,31 @@ function encodeV4ExactInSingle(currency0, currency1, amountIn, amountOutMin) { ); } +// Helper: V4 ExactOutputSingleParams — +// (tuple(address,address,uint24,int24,address) poolKey, bool zeroForOne, +// uint128 amountOut, uint128 amountInMaximum, bytes hookData) +function encodeV4ExactOutSingle( + currency0, + currency1, + amountOut, + amountInMaximum, +) { + return coder.encode( + [ + "tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)", + ], + [ + [ + [currency0, currency1, 100, 1, ZERO_ADDR], + true, // zeroForOne: in = currency0, out = currency1 + amountOut, + amountInMaximum, + "0x", + ], + ], + ); +} + // Each V4 swap action's parameters in two shapes: the struct V4Router has // deployed on mainnet and Sepolia, and the one v4-periphery `main` has since // moved to, which adds a `minHopPriceX36` field. Each swaps WETH for USDC. @@ -267,9 +293,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 +382,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 +404,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 +438,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 +447,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 +464,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 +496,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 +1159,190 @@ 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"); + }, + ); + + // The router runs a chain of exact-out actions from the output end: the + // first buys the USDC the user receives, owing WETH, and the second buys + // that WETH (the open delta) with the USDT the user pays. + test("two chained V4 exact-out actions show the last one's input and the first one's output", () => { + const data = buildExecute( + "0x10", + [ + encodeV4Swap( + new Uint8Array([ + V4_SWAP_EXACT_OUT_SINGLE_ID, + V4_SWAP_EXACT_OUT_SINGLE_ID, + ]), + [ + // exactly 990 USDC for at most 0.5 WETH + encodeV4ExactOutSingle( + WETH_ADDR, + USDC_ADDR, + 990000000n, + 500000000000000000n, + ), + // exactly that WETH (the open delta) for at most + // 1,000 USDT + encodeV4ExactOutSingle( + USDT_ADDR, + WETH_ADDR, + 0n, + 1000000000n, + ), + ], + ), + ], + 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, "Token Out").address).toBe(USDC_ADDR); + expect(detail(result, "Amount")).toBeUndefined(); + expect(detail(result, "Min. received")).toBeUndefined(); + }); + + // A swap action that could not be read still stands first or last in its + // step, so the side and figure it would have given are unknown, while + // those from the readable action are shown. The unread action is + // V4_SWAP_ACTION_SHAPES[0].newer, a SWAP_EXACT_IN_SINGLE of WETH to USDC. + test("a readable V4 swap action, then an unread one, shows the first one's input and no output", () => { + const data = buildExecute( + "0x10", + [ + encodeV4Swap( + new Uint8Array([ + V4_SWAP_EXACT_IN_SINGLE_ID, + V4_SWAP_EXACT_IN_SINGLE_ID, + ]), + [ + encodeV4ExactInSingle( + USDT_ADDR, + WETH_ADDR, + 1000000000n, + 400000000000000000n, + ), + V4_SWAP_ACTION_SHAPES[0].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").address).toBe(USDT_ADDR); + expect(detail(result, "Amount").value).toBe("1000.0000 USDT"); + expect(detail(result, "Token Out").value).toBe( + "Unknown (could not be decoded)", + ); + expect(detail(result, "Min. received")).toBeUndefined(); + }); + + test("an unread V4 swap action, then a readable one, shows the last one's output and no input", () => { + const data = buildExecute( + "0x10", + [ + encodeV4Swap( + new Uint8Array([ + V4_SWAP_EXACT_IN_SINGLE_ID, + V4_SWAP_EXACT_IN_SINGLE_ID, + ]), + [ + V4_SWAP_ACTION_SHAPES[0].newer, + encodeV4ExactInSingle( + USDC_ADDR, + DAI_ADDR, + 0n, + 990000000000000000000n, + ), + ], + ), + ], + 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)", + ); + expect(detail(result, "Amount")).toBeUndefined(); + expect(detail(result, "Token Out").address).toBe(DAI_ADDR); + expect(detail(result, "Min. received").value).toBe("990.0000 DAI"); + }); + // 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