diff --git a/README.md b/README.md index c23418b..e152857 100644 --- a/README.md +++ b/README.md @@ -1195,9 +1195,10 @@ 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 -with a swap action always sets the output side, and sets the input side only -when no earlier step set it. It finds each side by three rules: +the transaction could not be read or the transaction has an `EXECUTE_SUB_PLAN` +step, which leaves both sides unknown (see Transaction Decoding below). A V4 +step with a swap action always sets the output side, and sets the input side +only when no earlier step set it. It finds each side by three rules: 1. Its input side is the currency its `SETTLE` or `SETTLE_ALL` names, or else the one currency that some swap action in the step spends and none buys. Its @@ -2892,7 +2893,18 @@ Currently supported: 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)`. + `V4 Swap (could not be decoded)`. Each step is named by its command type, + which the decoder reads from the command byte with the mask the router at that + address uses: `0x1f` for `UniversalRouterV1`, `0x3f` for + `UniversalRouterV1_2_V2Support` and `UniversalRouterV2`, and `0x7f` for + `UniversalRouterV2_1_1` and `UniversalRouterV2_1_2`. The allow-revert flag + (`0x80`) therefore does not change a step's name. The decoder does not read + the commands inside an `EXECUTE_SUB_PLAN` (`0x21`) step, and wherever that + step stands they may spend the user's tokens or pay the user. That step is + listed as `Execute Sub-Plan (its steps are not shown)`, and whatever the other + steps state, the summary names no swap direction, `Token In` and `Token Out` + read `Unknown (could not be decoded)`, and there is no `Amount` or + `Min. received` line. ### Non-Goals Forever diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index fe9896a..53b6242 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -19,39 +19,54 @@ const ROUTER_IFACE = new Interface([ // The Universal Router deployments on each network, by chain id, copied from // the deployment files Uniswap publishes, each address named by its key there. // The files also list `UnsupportedProtocol`, which is not a router. +// +// Beside each address is the mask that router reads a command's type with: +// its dispatcher runs `commandType & Commands.COMMAND_TYPE_MASK`, and the bits +// above the mask are the allow-revert flag (0x80) or ignored. The mask grew +// between releases. Each is `COMMAND_TYPE_MASK` in the release's +// `contracts/libraries/Commands.sol`, the same file as in the source verified +// for that address: +// - 0x1f, UniversalRouterV1: https://github.com/Uniswap/universal-router/blob/cda697da221e24ca143bc4f55023694a2a16796f/contracts/libraries/Commands.sol +// - 0x3f, UniversalRouterV1_2_V2Support: https://github.com/Uniswap/universal-router/blob/41183d6eb154f0ab0e74a0e911a5ef9ea51fc4bd/contracts/libraries/Commands.sol +// - 0x3f, UniversalRouterV2: https://github.com/Uniswap/universal-router/blob/3663f6db6e2fe121753cd2d899699c2dc75dca86/contracts/libraries/Commands.sol +// - 0x7f, UniversalRouterV2_1_1: https://github.com/Uniswap/universal-router/blob/999d561c3ad58fb5cab91b602911f3c75591a9c7/contracts/libraries/Commands.sol +// - 0x7f, UniversalRouterV2_1_2: https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/contracts/libraries/Commands.sol const ROUTER_ADDRESSES = new Map([ [ NETWORKS.mainnet.chainId, // https://github.com/Uniswap/universal-router/blob/543e1a19d6e21e31ced2512eec5792b50f13a0ba/deploy-addresses/mainnet.json [ - "0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B", // UniversalRouterV1 - "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", // UniversalRouterV1_2_V2Support - "0x66a9893cc07d91d95644aedd05d03f95e1dba8af", // UniversalRouterV2 - "0x4C82D1fBFe28C977cBB58D8C7FF8FCF9F70a2cCA", // UniversalRouterV2_1_1 - "0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85", // UniversalRouterV2_1_2 + ["0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B", 0x1f], // UniversalRouterV1 + ["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", 0x3f], // UniversalRouterV1_2_V2Support + ["0x66a9893cc07d91d95644aedd05d03f95e1dba8af", 0x3f], // UniversalRouterV2 + ["0x4C82D1fBFe28C977cBB58D8C7FF8FCF9F70a2cCA", 0x7f], // UniversalRouterV2_1_1 + ["0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85", 0x7f], // UniversalRouterV2_1_2 ], ], [ NETWORKS.sepolia.chainId, // https://github.com/Uniswap/universal-router/blob/543e1a19d6e21e31ced2512eec5792b50f13a0ba/deploy-addresses/sepolia.json [ - "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", // UniversalRouterV1_2_V2Support - "0x3a9d48ab9751398bbfa63ad67599bb04e4bdf98b", // UniversalRouterV2 - "0x7DfD4F31be6814D2906BDE155c3e1B146EAc1468", // UniversalRouterV2_1_1 - "0x7E4f6c5e954Da5c61B3423D81E2277431Ac043f3", // UniversalRouterV2_1_2 + ["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", 0x3f], // UniversalRouterV1_2_V2Support + ["0x3a9d48ab9751398bbfa63ad67599bb04e4bdf98b", 0x3f], // UniversalRouterV2 + ["0x7DfD4F31be6814D2906BDE155c3e1B146EAc1468", 0x7f], // UniversalRouterV2_1_1 + ["0x7E4f6c5e954Da5c61B3423D81E2277431Ac043f3", 0x7f], // UniversalRouterV2_1_2 ], ], ]); -// Whether `address` is a Universal Router deployment on the network with this -// chain id. Address case is ignored. -function isRouterAddress(address, chainId) { +// The mask the Universal Router deployment at `address` on the network with +// this chain id reads a command's type with, or null when `address` is not one +// of them. Address case is ignored. +function routerCommandTypeMask(address, chainId) { const routers = ROUTER_ADDRESSES.get(chainId) || []; const wanted = (address || "").toLowerCase(); - return routers.some((router) => router.toLowerCase() === wanted); + const router = routers.find(([a]) => a.toLowerCase() === wanted); + return router ? router[1] : null; } -// Universal Router command IDs (lower 5 bits of each command byte) +// Universal Router command types, each read from its command byte with the +// router's mask (see ROUTER_ADDRESSES) const COMMAND_NAMES = { 0x00: "V3 Swap (Exact In)", 0x01: "V3 Swap (Exact Out)", @@ -90,8 +105,10 @@ function formatAmount(raw, decimals) { 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. +// decodeExactly()), or the transaction has an EXECUTE_SUB_PLAN step, whose +// commands this decoder does not read. That action names both of its +// currencies, and those commands may name one, so saying the calldata did not +// name it would be false. const UNDECODED_CURRENCY = "Unknown (could not be decoded)"; // Explicit presence, never truthiness. Every gate in this file that guards a @@ -603,7 +620,8 @@ function decodeV4Swap(input) { // sent to any other contract is not a swap through the router, whatever that // contract does with it. function decode(data, toAddress, chainId, sources) { - if (!isRouterAddress(toAddress, chainId)) return null; + const commandTypeMask = routerCommandTypeMask(toAddress, chainId); + if (commandTypeMask === null) return null; try { const parsed = ROUTER_IFACE.parseTransaction({ data }); if (!parsed) return null; @@ -621,6 +639,7 @@ function decode(data, toAddress, chainId, sources) { let hasUnwrapWeth = false; let hasV2ExactOut = false; let swapUndecoded = false; + let hasSubPlan = false; const commandNames = []; // THE INVARIANT: an amount and the token it is counted in always come @@ -660,13 +679,21 @@ function decode(data, toAddress, chainId, sources) { }; for (let i = 0; i < commandsBytes.length; i++) { - const cmdId = commandsBytes[i] & 0x1f; + const cmdId = commandsBytes[i] & commandTypeMask; commandNames.push( COMMAND_NAMES[cmdId] || "Command 0x" + cmdId.toString(16).padStart(2, "0"), ); try { + if (cmdId === 0x21) { + // EXECUTE_SUB_PLAN runs a further list of commands held in + // its input, which this decoder does not read. The step + // says so, and after this loop neither side is named. + hasSubPlan = true; + commandNames[i] += " (its steps are not shown)"; + } + if (cmdId === 0x0a) { const p = decodePermit2(inputs[i]); // A permit states both halves itself, so it may replace an @@ -767,14 +794,28 @@ function decode(data, toAddress, chainId, sources) { } } + // The commands of an EXECUTE_SUB_PLAN step may spend the user's tokens + // or pay the user, before or after any other step, and a permit among + // them would replace the input side. What the other steps state is + // then not the whole swap, so both sides are set unknown, with no + // figure, wherever the step stands. + if (hasSubPlan) { + inputToken = null; + inputAmount = null; + outputToken = null; + minOutput = null; + outputEstablished = true; + } + // Resolve token info. A null token on either side means no step named - // a currency for it, or a V4 step with a swap action left it - // unknown; 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. A V4 step - // with a swap action did set it, even when it set it unknown. 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. + // a currency for it, a V4 step with a swap action left it unknown, or + // an EXECUTE_SUB_PLAN step did; 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. A + // V4 step with a swap action, or an EXECUTE_SUB_PLAN step, did set it, + // even when it set it unknown. 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()); @@ -819,7 +860,10 @@ function decode(data, toAddress, chainId, sources) { // screen must not describe the same condition in two ways. details.push({ label: "Token In", - value: swapUndecoded ? UNDECODED_CURRENCY : UNNAMED_CURRENCY, + value: + swapUndecoded || hasSubPlan + ? UNDECODED_CURRENCY + : UNNAMED_CURRENCY, }); } @@ -885,7 +929,10 @@ function decode(data, toAddress, chainId, sources) { // calldata did not state. details.push({ label: "Token Out", - value: swapUndecoded ? UNDECODED_CURRENCY : UNNAMED_CURRENCY, + value: + swapUndecoded || hasSubPlan + ? UNDECODED_CURRENCY + : UNNAMED_CURRENCY, }); } diff --git a/tests/uniswap.test.js b/tests/uniswap.test.js index 199f1b2..098ff41 100644 --- a/tests/uniswap.test.js +++ b/tests/uniswap.test.js @@ -1,8 +1,12 @@ const { AbiCoder, Interface, solidityPacked } = require("ethers"); const uniswap = require("../src/shared/uniswap"); -// A Universal Router on Ethereum Mainnet, chain id 0x1. +// A Universal Router on Ethereum Mainnet, chain id 0x1: UniversalRouterV2, +// which reads a command's type with the mask 0x3f. const ROUTER_ADDR = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af"; +// Two more there, which read it with 0x1f and 0x7f. +const ROUTER_V1_ADDR = "0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B"; +const ROUTER_V2_1_2_ADDR = "0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85"; const USDT_ADDR = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; const WETH_ADDR = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; const USDC_ADDR = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; @@ -105,6 +109,12 @@ function encodeV4Swap(actions, params) { return coder.encode(["bytes", "bytes[]"], [actions, params]); } +// Helper: encode an EXECUTE_SUB_PLAN input (command 0x21), which holds the +// sub-plan's own commands and inputs +function encodeSubPlan(commands, inputs) { + return coder.encode(["bytes", "bytes[]"], [commands, inputs]); +} + // 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; @@ -864,6 +874,177 @@ describe("uniswap decoder", () => { ); }); + // https://git.eeqj.de/sneak/AutistMask/issues/535 — every command's type + // was read from its low five bits, as only UniversalRouterV1 reads it, so + // the later routers listed 0x21 as a V3 exact-out swap, and + // UniversalRouterV2_1_2 listed 0x40 as a V3 exact-in swap. + test.each([ + { + router: "UniversalRouterV1", + address: ROUTER_V1_ADDR, + command: "0x21", + steps: "V3 Swap (Exact Out)", + }, + { + router: "UniversalRouterV2", + address: ROUTER_ADDR, + command: "0x21", + steps: "Execute Sub-Plan (its steps are not shown)", + }, + { + router: "UniversalRouterV2_1_2", + address: ROUTER_V2_1_2_ADDR, + command: "0x21", + steps: "Execute Sub-Plan (its steps are not shown)", + }, + { + router: "UniversalRouterV1", + address: ROUTER_V1_ADDR, + command: "0x40", + steps: "V3 Swap (Exact In)", + }, + { + router: "UniversalRouterV2", + address: ROUTER_ADDR, + command: "0x40", + steps: "V3 Swap (Exact In)", + }, + { + router: "UniversalRouterV2_1_2", + address: ROUTER_V2_1_2_ADDR, + command: "0x40", + steps: "Command 0x40", + }, + ])( + "command $command sent to $router is listed as $steps", + ({ address, command, steps }) => { + const data = buildExecute(command, ["0x"], 9999999999n); + const result = uniswap.decode(data, address, "0x1"); + expect(detail(result, "Steps").value).toBe(steps); + }, + ); + + // A sub-plan holding a V3 swap of USDT for WETH, which the decoder does + // not read. With the allow-revert flag (0x80) the command byte is 0xa1. + test.each([ + { flag: "without", command: 0x21 }, + { flag: "with", command: 0xa1 }, + ])( + "an EXECUTE_SUB_PLAN step $flag the allow-revert flag says its steps are not shown", + ({ command }) => { + const subPlan = encodeSubPlan("0x00", [ + encodeV3SwapExactIn(USER_ADDR, 2000000n, ONE_WETH, [ + USDT_ADDR, + WETH_ADDR, + ]), + ]); + const data = buildExecute( + solidityPacked(["uint8"], [command]), + [subPlan], + 9999999999n, + ); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + expect(result.name).toBe("Uniswap Swap"); + expect(detail(result, "Steps").value).toBe( + "Execute Sub-Plan (its steps are not shown)", + ); + // The sub-plan names both currencies, so the lines must not say + // the calldata did not. + expect(detail(result, "Token In").value).toBe( + "Unknown (could not be decoded)", + ); + expect(detail(result, "Token Out").value).toBe( + "Unknown (could not be decoded)", + ); + expect(detail(result, "Amount")).toBeUndefined(); + expect(detail(result, "Min. received")).toBeUndefined(); + }, + ); + + // The steps inside a sub-plan may spend the user's tokens or pay the user, + // so the steps read beside it are not the whole swap, whether they come + // before it or after it. No side is named and no figure is given. + test.each([ + { + // 2,000 USDT for at least 1 WETH, paid to the router; the sub-plan + // swaps that WETH for USDC. + beside: "a swap before", + commands: "0x0021", + inputs: [ + encodeV3SwapExactIn(ROUTER_ADDR, 2000000000n, ONE_WETH, [ + USDT_ADDR, + WETH_ADDR, + ]), + encodeSubPlan("0x00", [ + encodeV3SwapExactIn(USER_ADDR, ONE_WETH, 2000000000n, [ + WETH_ADDR, + USDC_ADDR, + ]), + ]), + ], + steps: "V3 Swap (Exact In) \u2192 Execute Sub-Plan (its steps are not shown)", + }, + { + // The sub-plan buys WETH with the user's USDC; 1 WETH is then + // swapped for at least 2,000 DAI. + beside: "a swap after", + commands: "0x2100", + inputs: [ + encodeSubPlan("0x00", [ + encodeV3SwapExactIn(ROUTER_ADDR, 2000000000n, ONE_WETH, [ + USDC_ADDR, + WETH_ADDR, + ]), + ]), + encodeV3SwapExactIn(USER_ADDR, ONE_WETH, 2000n * ONE_WETH, [ + WETH_ADDR, + DAI_ADDR, + ]), + ], + steps: "Execute Sub-Plan (its steps are not shown) \u2192 V3 Swap (Exact In)", + }, + { + // A permit among the sub-plan's steps would replace this one. + beside: "a permit before", + commands: "0x0a21", + inputs: [encodePermit2(USDT_ADDR, 1000000n, ROUTER_ADDR), "0x"], + steps: "Permit2 Permit \u2192 Execute Sub-Plan (its steps are not shown)", + }, + { + // The sub-plan buys USDC for the user; an UNWRAP_WETH of 0 + // follows. No step outside the sub-plan sets the output side, and + // UNWRAP_WETH would then make Token Out ETH. + beside: "an UNWRAP_WETH after", + commands: "0x210c", + inputs: [ + encodeSubPlan("0x00", [ + encodeV3SwapExactIn(USER_ADDR, 2000000000n, 1900000000n, [ + USDT_ADDR, + USDC_ADDR, + ]), + ]), + encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH, same encoding + ], + steps: "Execute Sub-Plan (its steps are not shown) \u2192 Unwrap WETH", + }, + ])( + "$beside an EXECUTE_SUB_PLAN step does not name either side", + ({ commands, inputs, steps }) => { + const data = buildExecute(commands, inputs, 9999999999n); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + expect(detail(result, "Steps").value).toBe(steps); + expect(result.name).toBe("Uniswap Swap"); + expect(detail(result, "Token In").value).toBe( + "Unknown (could not be decoded)", + ); + expect(detail(result, "Amount")).toBeUndefined(); + expect(detail(result, "Token Out").value).toBe( + "Unknown (could not be decoded)", + ); + expect(detail(result, "Min. received")).toBeUndefined(); + }, + ); + test("shows the deadline as a UTC date and time", () => { const result = uniswap.decode(FIRST_SWAP_CALLDATA, ROUTER_ADDR, "0x1"); expect(detail(result, "Deadline").value).toBe("2026-02-27 08:25:51");