From ec1254204c9234ac668664082eaae2fbfd05bd3b Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 14 Oct 2026 02:21:26 +0000 Subject: [PATCH] fix: a V4 swap action not in the deployed shape is shown as not decoded (closes #358) The four V4 swap actions are read only as the parameter structs deployed on mainnet and Sepolia. ethers decoded the newer v4-periphery structs, which add minHopPriceX36, as the deployed ones without an error, and the newer multi-hop exact-in struct showed the offset of its hop-price array as the swap amount. A decode now counts only when encoding it again gives back the same bytes. When a swap action fails that, its step is listed as "V4 Swap (could not be decoded)", and a token line left without a currency reads "Unknown (could not be decoded)" rather than "not named in the calldata". The README records both. Model: opus-5-5 --- README.md | 24 +-- src/shared/uniswap.js | 74 +++++++-- tests/uniswap.test.js | 184 ++++++++++++++++++++++- tests/uniswapUndeterminedTokenIn.test.js | 6 +- 4 files changed, 259 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 54a5f07..e8f0c64 100644 --- a/README.md +++ b/README.md @@ -1110,14 +1110,16 @@ 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. 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. +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). 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 @@ -2690,7 +2692,11 @@ Currently supported: or recipient, and amount. - **Uniswap Universal Router**: `execute()` calls — shows swap direction (e.g. "Swap USDT → ETH"), token addresses, amounts, execution steps, and deadline. - Decodes Permit2, V2/V3/V4 swaps, wrap/unwrap, and balance checks. + Decodes Permit2, V2/V3/V4 swaps, wrap/unwrap, and balance checks. A V4 swap + action is read only in the parameter shape deployed on mainnet and Sepolia, + not in the newer one on `v4-periphery` `main`, which adds a `minHopPriceX36` + field. An action in any other shape is not read, and its step is listed as + `V4 Swap (could not be decoded)`. ### Non-Goals Forever diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index 0b62a8c..6780a5e 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -53,6 +53,11 @@ function formatAmount(raw, decimals) { // on a scale — not as a token name, and not as a quantity. const UNNAMED_CURRENCY = "Unknown (not named in the calldata)"; +// The same refusal when a V4 swap action could not be read (see +// decodeExactly()). That action names both of its currencies, so saying the +// calldata did not name one would be false. +const UNDECODED_CURRENCY = "Unknown (could not be decoded)"; + // Explicit presence, never truthiness. Every gate in this file that guards a // decoded value goes through here: an address is never falsy once set, but an // amount of 0n is, and a gate that cannot tell a genuine zero from an absent @@ -295,6 +300,24 @@ function v4ExactInAmount(raw) { return raw === 0n ? OPEN_DELTA : raw; } +// The V4 swap actions are read only in the parameter structs V4Router has +// deployed on mainnet and Sepolia. v4-periphery `main` has since added a +// `minHopPriceX36` field to all four (`src/interfaces/IV4Router.sol`), and +// reading a shape nobody has deployed would be guessing. ethers alone would +// read the newer structs anyway: it takes whatever word sits where a +// deployed field would be, ignores bytes left over, and holds back an error +// in a field until that field is used. The newer multi-hop exact-in struct +// then yields the offset of its hop-price array as `amountIn`. So a decode +// counts only when encoding the result again gives back the same bytes; +// otherwise this throws, and decodeV4Swap() marks the step as not decoded. +function decodeExactly(types, data) { + const decoded = coder.decode(types, data); + if (coder.encode(types, decoded) !== data) { + throw new Error("Not in the deployed V4 parameter shape."); + } + return decoded; +} + // 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, @@ -309,6 +332,10 @@ function decodeV4Swap(input) { let takeToken = null; let amountIn = null; let amountOutMin = null; + // 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. + let swapUndecoded = false; for (let i = 0; i < actions.length; i++) { const actionId = actions[i]; @@ -337,7 +364,7 @@ function decodeV4Swap(input) { // tuple(address,uint24,int24,address,bytes)[] path, // uint128 amountIn, uint128 amountOutMin) try { - const s = coder.decode( + const s = decodeExactly( [ "tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)", ], @@ -355,13 +382,13 @@ function decodeV4Swap(input) { amountOutMin = s[0][3]; } } catch { - // Fall through — SETTLE/TAKE will provide tokens + swapUndecoded = true; } } else { // ExactInputSingleParams: (tuple(address,address,uint24,int24,address) poolKey, // bool zeroForOne, uint128 amountIn, uint128 amountOutMin, bytes hookData) try { - const s = coder.decode( + const s = decodeExactly( [ "tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)", ], @@ -384,7 +411,7 @@ function decodeV4Swap(input) { amountOutMin = s[0][3]; } } catch { - // Fall through + swapUndecoded = true; } } } else if ( @@ -393,7 +420,7 @@ function decodeV4Swap(input) { ) { if (actionId === V4_SWAP_EXACT_OUT) { try { - const s = coder.decode( + const s = decodeExactly( [ "tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)", ], @@ -405,11 +432,11 @@ function decodeV4Swap(input) { settleToken = path[path.length - 1][0]; } } catch { - // Fall through + swapUndecoded = true; } } else { try { - const s = coder.decode( + const s = decodeExactly( [ "tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)", ], @@ -426,7 +453,7 @@ function decodeV4Swap(input) { ? poolKey[1] : poolKey[0]; } catch { - // Fall through + swapUndecoded = true; } } } @@ -440,6 +467,7 @@ function decodeV4Swap(input) { tokenOut: takeToken, amountIn, amountOutMin, + swapUndecoded, }; } catch { return null; @@ -465,6 +493,7 @@ function decode(data, toAddress, sources) { let minOutput = null; let hasUnwrapWeth = false; let hasV2ExactOut = false; + let swapUndecoded = false; const commandNames = []; // THE INVARIANT: an amount and the token it is counted in always come @@ -568,6 +597,12 @@ function decode(data, toAddress, sources) { if (cmdId === 0x10) { const v4 = decodeV4Swap(inputs[i]); if (v4) { + // Without this, a swap action that went unread would + // look like one that had nothing to show. + if (v4.swapUndecoded) { + swapUndecoded = true; + commandNames[i] += " (could not be decoded)"; + } setInputOnce(v4.tokenIn, v4.amountIn); // Always update output: last swap step wins. A step // that carries the Min. received figure but decoded no @@ -591,12 +626,13 @@ function decode(data, toAddress, sources) { } } - // Resolve token info. A null token on either side means the calldata - // named no currency for it; tokenInfo() refuses rather than calling it - // ETH. UNWRAP_WETH turns WETH into ETH, so it makes the output ETH - // when the output side is WETH, or when no step set the output side. - // Any other output keeps its own token and figure: a swap that buys - // USDC and then unwraps the WETH it did not spend receives USDC. + // Resolve token info. A null token on either side means no step that + // was read named a currency for it; tokenInfo() refuses rather than + // calling it ETH. UNWRAP_WETH turns WETH into ETH, so it makes the + // output ETH when the output side is WETH, or when no step set the + // output side. Any other output keeps its own token and figure: a swap + // that buys USDC and then unwraps the WETH it did not spend receives + // USDC. const outputIsWeth = present(outputToken) && WETH_ADDRESSES.includes(outputToken.toLowerCase()); @@ -640,7 +676,10 @@ function decode(data, toAddress, sources) { // rather than going missing or naming a token by default. Same // wording as the Token Out refusal below: the two sides of this // screen must not describe the same condition in two ways. - details.push({ label: "Token In", value: UNNAMED_CURRENCY }); + details.push({ + label: "Token In", + value: swapUndecoded ? UNDECODED_CURRENCY : UNNAMED_CURRENCY, + }); } if (present(inputAmount)) { @@ -703,7 +742,10 @@ function decode(data, toAddress, sources) { // rather than going missing or naming a token by default, and a // Min. received figure below it is never attached to a token the // calldata did not state. - details.push({ label: "Token Out", value: UNNAMED_CURRENCY }); + details.push({ + label: "Token Out", + value: swapUndecoded ? UNDECODED_CURRENCY : UNNAMED_CURRENCY, + }); } if (present(minOutput)) { diff --git a/tests/uniswap.test.js b/tests/uniswap.test.js index 952a340..6a23ee3 100644 --- a/tests/uniswap.test.js +++ b/tests/uniswap.test.js @@ -163,6 +163,110 @@ function encodeV4ExactInSingle(currency0, currency1, amountIn, amountOutMin) { ); } +// 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. +const POOL_KEY = "tuple(address,address,uint24,int24,address)"; +const PATH_KEY = "tuple(address,uint24,int24,address,bytes)"; +const WETH_USDC_POOL = [WETH_ADDR, USDC_ADDR, 100, 1, ZERO_ADDR]; +const ONE_WETH = 1000000000000000000n; +const V4_SWAP_ACTION_SHAPES = [ + { + action: "SWAP_EXACT_IN_SINGLE", + id: 0x06, + deployed: coder.encode( + [`tuple(${POOL_KEY},bool,uint128,uint128,bytes)`], + [[WETH_USDC_POOL, true, ONE_WETH, 990000n, "0x"]], + ), + newer: coder.encode( + [`tuple(${POOL_KEY},bool,uint128,uint128,uint256,bytes)`], + [[WETH_USDC_POOL, true, ONE_WETH, 990000n, 0n, "0x"]], + ), + }, + { + action: "SWAP_EXACT_IN", + id: 0x07, + deployed: coder.encode( + [`tuple(address,${PATH_KEY}[],uint128,uint128)`], + [ + [ + WETH_ADDR, + [[USDC_ADDR, 100, 1, ZERO_ADDR, "0x"]], + ONE_WETH, + 990000n, + ], + ], + ), + newer: coder.encode( + [`tuple(address,${PATH_KEY}[],uint256[],uint128,uint128)`], + [ + [ + WETH_ADDR, + [[USDC_ADDR, 100, 1, ZERO_ADDR, "0x"]], + [], + ONE_WETH, + 990000n, + ], + ], + ), + }, + { + action: "SWAP_EXACT_OUT_SINGLE", + id: 0x08, + deployed: coder.encode( + [`tuple(${POOL_KEY},bool,uint128,uint128,bytes)`], + [[WETH_USDC_POOL, true, 990000n, ONE_WETH, "0x"]], + ), + newer: coder.encode( + [`tuple(${POOL_KEY},bool,uint128,uint128,uint256,bytes)`], + [[WETH_USDC_POOL, true, 990000n, ONE_WETH, 0n, "0x"]], + ), + }, + { + action: "SWAP_EXACT_OUT", + id: 0x09, + deployed: coder.encode( + [`tuple(address,${PATH_KEY}[],uint128,uint128)`], + [ + [ + USDC_ADDR, + [[WETH_ADDR, 100, 1, ZERO_ADDR, "0x"]], + 990000n, + ONE_WETH, + ], + ], + ), + newer: coder.encode( + [`tuple(address,${PATH_KEY}[],uint256[],uint128,uint128)`], + [ + [ + USDC_ADDR, + [[WETH_ADDR, 100, 1, ZERO_ADDR, "0x"]], + [], + 990000n, + ONE_WETH, + ], + ], + ), + }, +]; + +// Helper: execute() with one V4_SWAP step holding the swap action, then +// SETTLE of WETH and TAKE of USDC. +function v4SwapCalldata(actionId, swapParams) { + return buildExecute( + "0x10", + [ + encodeV4Swap(new Uint8Array([actionId, V4_SETTLE_ID, V4_TAKE_ID]), [ + swapParams, + encodeV4Settle(WETH_ADDR), + encodeV4Take(USDC_ADDR), + ]), + ], + 9999999999n, + ); +} + function detail(result, label) { return result.details.find((d) => d.label === label); } @@ -203,11 +307,83 @@ describe("uniswap decoder", () => { result.details.find((d) => d.label === "Min. received").value, ).toBe("0.0002 ETH"); + // Real mainnet calldata is in the deployed shape, so its V4 step is + // not said to be undecoded. const steps = result.details.find((d) => d.label === "Steps"); - expect(steps.value).toContain("Permit2 Permit"); - expect(steps.value).toContain("V4 Swap"); + expect(steps.value).toBe("Permit2 Permit → V4 Swap"); }); + test.each(V4_SWAP_ACTION_SHAPES)( + "a V4 $action in the deployed shape is read", + ({ id, deployed }) => { + const result = uniswap.decode( + v4SwapCalldata(id, deployed), + ROUTER_ADDR, + ); + expect(detail(result, "Steps").value).toBe("V4 Swap"); + }, + ); + + // https://git.eeqj.de/sneak/AutistMask/issues/358 — before it, all four + // newer structs decoded as the deployed ones without an error, and the + // newer SWAP_EXACT_IN showed the offset of its hop-price array as Amount + // ("0.0000000000000004 WETH") and its amountIn as Min. received. + test.each(V4_SWAP_ACTION_SHAPES)( + "a V4 $action in the newer periphery shape is shown as not decoded", + ({ id, newer }) => { + const result = uniswap.decode( + v4SwapCalldata(id, newer), + ROUTER_ADDR, + ); + expect(detail(result, "Amount")).toBeUndefined(); + expect(detail(result, "Min. received")).toBeUndefined(); + expect(detail(result, "Steps").value).toBe( + "V4 Swap (could not be decoded)", + ); + // SETTLE and TAKE keep their own shape and still name both sides. + expect(detail(result, "Token In").address).toBe(WETH_ADDR); + expect(detail(result, "Token Out").address).toBe(USDC_ADDR); + }, + ); + + // The decoder does not read SETTLE_ALL or TAKE_ALL, so here only the + // unread swap action names the currencies. The token lines must not say + // the calldata did not name them. + 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", + ({ id, newer }) => { + const data = buildExecute( + "0x10", + [ + // 0x0c is SETTLE_ALL (currency, maxAmount), 0x0f is + // TAKE_ALL (currency, minAmount). + encodeV4Swap(new Uint8Array([id, 0x0c, 0x0f]), [ + newer, + coder.encode( + ["address", "uint256"], + [WETH_ADDR, ONE_WETH], + ), + coder.encode( + ["address", "uint256"], + [USDC_ADDR, 990000n], + ), + ]), + ], + 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, "Token Out").value).toBe( + "Unknown (could not be decoded)", + ); + }, + ); + test("decodes V2_SWAP_EXACT_IN with known tokens", () => { const data = buildExecute( "0x08", // V2_SWAP_EXACT_IN @@ -548,9 +724,11 @@ describe("uniswap decoder", () => { const result = uniswap.decode(data, ROUTER_ADDR); expect(result).not.toBeNull(); + // The V4 swap action's parameters are empty, so that step is said to + // be undecoded. const steps = result.details.find((d) => d.label === "Steps"); expect(steps.value).toBe( - "Permit2 Permit \u2192 V4 Swap \u2192 Unwrap WETH", + "Permit2 Permit \u2192 V4 Swap (could not be decoded) \u2192 Unwrap WETH", ); }); diff --git a/tests/uniswapUndeterminedTokenIn.test.js b/tests/uniswapUndeterminedTokenIn.test.js index ae0061d..f8266ec 100644 --- a/tests/uniswapUndeterminedTokenIn.test.js +++ b/tests/uniswapUndeterminedTokenIn.test.js @@ -97,7 +97,11 @@ describe("an execute() whose input currency never decoded", () => { ]); test("says the input token is unknown instead of naming ETH", () => { - expect(detail(data(), "Token In").value).toBe(REFUSAL); + // The swap action that would name it could not be read, so the line + // says that rather than REFUSAL's "not named in the calldata". + expect(detail(data(), "Token In").value).toBe( + "Unknown (could not be decoded)", + ); }); test("keeps a Token In line, so the screen never omits what is paid", () => {