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", ); });