From 0c88dcbda9d1f5098068baa83aa775a07363f456 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, the Steps line lists its step as "V4 Swap (could not be decoded)", so the screen no longer looks complete without it. The README records the pinned shape. Model: opus-5-5 --- README.md | 6 +- src/shared/uniswap.js | 44 ++++++++++--- tests/uniswap.test.js | 146 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 184 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index a4505e2..c027146 100644 --- a/README.md +++ b/README.md @@ -2678,7 +2678,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..15af874 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -295,6 +295,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 +327,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 +359,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 +377,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 +406,7 @@ function decodeV4Swap(input) { amountOutMin = s[0][3]; } } catch { - // Fall through + swapUndecoded = true; } } } else if ( @@ -393,7 +415,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 +427,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 +448,7 @@ function decodeV4Swap(input) { ? poolKey[1] : poolKey[0]; } catch { - // Fall through + swapUndecoded = true; } } } @@ -440,6 +462,7 @@ function decodeV4Swap(input) { tokenOut: takeToken, amountIn, amountOutMin, + swapUndecoded, }; } catch { return null; @@ -568,6 +591,11 @@ 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) { + 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 diff --git a/tests/uniswap.test.js b/tests/uniswap.test.js index 952a340..90d87ce 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,45 @@ 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); + }, + ); + test("decodes V2_SWAP_EXACT_IN with known tokens", () => { const data = buildExecute( "0x08", // V2_SWAP_EXACT_IN @@ -548,9 +686,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", ); });