From d6fb002656fbdd51a72b372d994c9d8844b18e11 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 19 Oct 2026 18:00:12 +0200 Subject: [PATCH] fix: each router release names and reads its own commands (closes #592) Each Universal Router release now carries the command it runs for each command type, by the name its pinned Commands.sol gives it, and the decoder names and reads each step as that command instead of using one table for every router. On UniversalRouterV1 and UniversalRouterV1_2_V2Support, 0x10 to 0x13 are NFT commands, such as Seaport: they are no longer read as V4 commands, so they draw no swap lines and their payouts count as unread. UniversalRouterV1 has no BALANCE_CHECK_ERC20 and reverts on 0x0e, which is now listed as Command 0x0e and sets no minimum. No release lets a V4 swap fail, so the zero-minimum reading for a flagged one is gone. Model: opus-5-5 --- README.md | 37 ++-- src/shared/uniswap.js | 246 +++++++++++++++++------- tests/uniswapRecipient.test.js | 333 ++++++++++++++++++++++++++++++--- 3 files changed, 501 insertions(+), 115 deletions(-) diff --git a/README.md b/README.md index 24e1d92..c325f27 100644 --- a/README.md +++ b/README.md @@ -2983,15 +2983,25 @@ 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)`. 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 the command a step is read as. The router - reverts the whole transaction when a step fails, unless the step's command - byte has that flag set and its command is one the router lets fail, as each - release's `contracts/base/Dispatcher.sol` shows: on `UniversalRouterV2`, + `V4 Swap (could not be decoded)`. Each step is named and read as the command + that the release of the router at that address runs for its command type, as + that release's `contracts/libraries/Commands.sol` names it. The decoder reads + the type from the command byte with the mask that router 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 the command a step is read as. Releases run different commands for some + types: on `UniversalRouterV1` and `UniversalRouterV1_2_V2Support`, `0x10` to + `0x13` are NFT commands, where the later routers run `V4_SWAP`, + `V3_POSITION_MANAGER_PERMIT`, `V3_POSITION_MANAGER_CALL` and + `V4_INITIALIZE_POOL`. The decoder does not read those routers' NFT commands, + and lists each by its name there, for example `Seaport` for `SEAPORT` (`0x10` + on `UniversalRouterV1`). A type a release names no command for is listed by + its number, for example `Command 0x0e` on `UniversalRouterV1`, which has no + `BALANCE_CHECK_ERC20` and reverts on it. The router reverts the whole + transaction when a step fails, unless the step's command byte has that flag + set and its command is one the router lets fail, as each release's + `contracts/base/Dispatcher.sol` shows: on `UniversalRouterV2`, `UniversalRouterV2_1_1` and `UniversalRouterV2_1_2`, `PERMIT2_PERMIT`, `PERMIT2_PERMIT_BATCH`, `BALANCE_CHECK_ERC20`, `V3_POSITION_MANAGER_PERMIT`, `V3_POSITION_MANAGER_CALL`, `V4_INITIALIZE_POOL`, `V4_POSITION_MANAGER_CALL` @@ -3004,11 +3014,7 @@ Currently supported: V3 and V4 swap and each payment of ETH or an ERC-20 token, the flag has no effect: the step succeeds or the whole transaction reverts, so the step reads as it does without the flag. A `BALANCE_CHECK_ERC20` the router lets fail - guarantees nothing, so it is read as one whose `minBalance` is zero. On - `UniversalRouterV1` and `UniversalRouterV1_2_V2Support`, `0x10` is an NFT - market call the router lets fail, which the decoder still reads as a V4 swap - ([#592](https://git.eeqj.de/sneak/AutistMask/issues/592)); with the flag set, - a minimum it states is read as zero: `None (no minimum guaranteed)`. Only the + guarantees nothing, so it is read as one whose `minBalance` is zero. Only the routers with the `0x7f` mask have `PAY_PORTION_FULL_PRECISION` (`0x07`), listed as `Pay Portion (Full Precision)`, and `ACROSS_V4_DEPOSIT_V3` (`0x40`), listed as `Across Bridge Deposit`; the other routers revert on `0x07`, and it @@ -3083,7 +3089,8 @@ Currently supported: `V3_SWAP_EXACT_IN`, `V4_SWAP`, `SWEEP`, `TRANSFER`, `PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`, `ACROSS_V4_DEPOSIT_V3`, `WRAP_ETH`, `UNWRAP_WETH` and `BALANCE_CHECK_ERC20` (`V3_SWAP_EXACT_OUT`, - `PERMIT2_TRANSFER_FROM_BATCH` and `EXECUTE_SUB_PLAN` among them). + `PERMIT2_TRANSFER_FROM_BATCH`, `EXECUTE_SUB_PLAN` and the NFT commands among + them), or a type its release names no command for. ### Non-Goals Forever diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index 88c5a6a..fee7209 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -24,8 +24,8 @@ const ROUTER_IFACE = new Interface([ "function execute(bytes commands, bytes[] inputs, uint256 deadline)", ]); -// The Universal Router releases deployed at the addresses below, each with two -// things read from its source at the commit linked for it: +// The Universal Router releases deployed at the addresses below, each with +// three things read from its source at the commit linked for it: // // - `commandTypeMask`, the mask it reads a command's type with: its dispatcher // runs `commandType & Commands.COMMAND_TYPE_MASK`, and the bits above the @@ -33,6 +33,11 @@ const ROUTER_IFACE = new Interface([ // releases. Each is `COMMAND_TYPE_MASK` in the release's // `contracts/libraries/Commands.sol`, the same file as in the source // verified for its addresses. +// - `commands`, the command it runs for each command type, by the name that +// file gives it. Releases give some types different commands. A type the +// file names no command for, or only a placeholder, is left out: the router +// reverts on it, except UniversalRouterV1_2_V2Support on 0x14, which does +// nothing. decode() names and reads each step as its release's command. // - `allowRevert`, the command types it lets fail without reverting the // transaction. Its `execute()`, in `contracts/UniversalRouter.sol`, runs each // step through `dispatch()`, in `contracts/base/Dispatcher.sol`, and reverts @@ -46,6 +51,35 @@ const ROUTER_IFACE = new Interface([ // UniversalRouterV1: https://github.com/Uniswap/universal-router/tree/cda697da221e24ca143bc4f55023694a2a16796f/contracts const ROUTER_V1 = { commandTypeMask: 0x1f, + commands: { + 0x00: "V3_SWAP_EXACT_IN", + 0x01: "V3_SWAP_EXACT_OUT", + 0x02: "PERMIT2_TRANSFER_FROM", + 0x03: "PERMIT2_PERMIT_BATCH", + 0x04: "SWEEP", + 0x05: "TRANSFER", + 0x06: "PAY_PORTION", + 0x08: "V2_SWAP_EXACT_IN", + 0x09: "V2_SWAP_EXACT_OUT", + 0x0a: "PERMIT2_PERMIT", + 0x0b: "WRAP_ETH", + 0x0c: "UNWRAP_WETH", + 0x0d: "PERMIT2_TRANSFER_FROM_BATCH", + 0x10: "SEAPORT", + 0x11: "LOOKS_RARE_721", + 0x12: "NFTX", + 0x13: "CRYPTOPUNKS", + 0x14: "LOOKS_RARE_1155", + 0x15: "OWNER_CHECK_721", + 0x16: "OWNER_CHECK_1155", + 0x17: "SWEEP_ERC721", + 0x18: "X2Y2_721", + 0x19: "SUDOSWAP", + 0x1a: "NFT20", + 0x1b: "X2Y2_1155", + 0x1c: "FOUNDATION", + 0x1d: "SWEEP_ERC1155", + }, // Its NFT market calls (0x10 to 0x14, 0x18 to 0x1c) and NFT owner checks // (0x15, 0x16). allowRevert: [ @@ -55,6 +89,39 @@ const ROUTER_V1 = { // UniversalRouterV1_2_V2Support: https://github.com/Uniswap/universal-router/tree/41183d6eb154f0ab0e74a0e911a5ef9ea51fc4bd/contracts const ROUTER_V1_2 = { commandTypeMask: 0x3f, + commands: { + 0x00: "V3_SWAP_EXACT_IN", + 0x01: "V3_SWAP_EXACT_OUT", + 0x02: "PERMIT2_TRANSFER_FROM", + 0x03: "PERMIT2_PERMIT_BATCH", + 0x04: "SWEEP", + 0x05: "TRANSFER", + 0x06: "PAY_PORTION", + 0x08: "V2_SWAP_EXACT_IN", + 0x09: "V2_SWAP_EXACT_OUT", + 0x0a: "PERMIT2_PERMIT", + 0x0b: "WRAP_ETH", + 0x0c: "UNWRAP_WETH", + 0x0d: "PERMIT2_TRANSFER_FROM_BATCH", + 0x0e: "BALANCE_CHECK_ERC20", + 0x10: "SEAPORT_V1_5", + 0x11: "LOOKS_RARE_V2", + 0x12: "NFTX", + 0x13: "CRYPTOPUNKS", + 0x15: "OWNER_CHECK_721", + 0x16: "OWNER_CHECK_1155", + 0x17: "SWEEP_ERC721", + 0x18: "X2Y2_721", + 0x19: "SUDOSWAP", + 0x1a: "NFT20", + 0x1b: "X2Y2_1155", + 0x1c: "FOUNDATION", + 0x1d: "SWEEP_ERC1155", + 0x1e: "ELEMENT_MARKET", + 0x20: "SEAPORT_V1_4", + 0x21: "EXECUTE_SUB_PLAN", + 0x22: "APPROVE_ERC20", + }, // BALANCE_CHECK_ERC20 (0x0e), its NFT market calls (0x10 to 0x13, 0x18 to // 0x1c, 0x1e, 0x20), its NFT owner checks (0x15, 0x16) and // EXECUTE_SUB_PLAN (0x21). @@ -66,6 +133,28 @@ const ROUTER_V1_2 = { // UniversalRouterV2: https://github.com/Uniswap/universal-router/tree/3663f6db6e2fe121753cd2d899699c2dc75dca86/contracts const ROUTER_V2 = { commandTypeMask: 0x3f, + commands: { + 0x00: "V3_SWAP_EXACT_IN", + 0x01: "V3_SWAP_EXACT_OUT", + 0x02: "PERMIT2_TRANSFER_FROM", + 0x03: "PERMIT2_PERMIT_BATCH", + 0x04: "SWEEP", + 0x05: "TRANSFER", + 0x06: "PAY_PORTION", + 0x08: "V2_SWAP_EXACT_IN", + 0x09: "V2_SWAP_EXACT_OUT", + 0x0a: "PERMIT2_PERMIT", + 0x0b: "WRAP_ETH", + 0x0c: "UNWRAP_WETH", + 0x0d: "PERMIT2_TRANSFER_FROM_BATCH", + 0x0e: "BALANCE_CHECK_ERC20", + 0x10: "V4_SWAP", + 0x11: "V3_POSITION_MANAGER_PERMIT", + 0x12: "V3_POSITION_MANAGER_CALL", + 0x13: "V4_INITIALIZE_POOL", + 0x14: "V4_POSITION_MANAGER_CALL", + 0x21: "EXECUTE_SUB_PLAN", + }, // PERMIT2_PERMIT_BATCH (0x03), PERMIT2_PERMIT (0x0a), BALANCE_CHECK_ERC20 // (0x0e), V3_POSITION_MANAGER_PERMIT, V3_POSITION_MANAGER_CALL, // V4_INITIALIZE_POOL and V4_POSITION_MANAGER_CALL (0x11 to 0x14), and @@ -77,6 +166,12 @@ const ROUTER_V2 = { // The three files are the same in both releases. const ROUTER_V2_1 = { commandTypeMask: 0x7f, + // UniversalRouterV2's, and two more. + commands: { + ...ROUTER_V2.commands, + 0x07: "PAY_PORTION_FULL_PRECISION", + 0x40: "ACROSS_V4_DEPOSIT_V3", + }, // The same as UniversalRouterV2's. allowRevert: ROUTER_V2.allowRevert, }; @@ -171,34 +266,52 @@ function v2PairFor(chainId, tokenA, tokenB) { ); } -// Universal Router command types, each read from its command byte with the -// router's mask (see ROUTER_V1). Only the routers with the 0x7f mask -// have 0x07 and 0x40: 0x40 is above the other masks, and the other routers -// revert on 0x07, so decode() names it there as it names a command not -// listed here. +// The Steps line's name for each command in the releases' `commands` (see +// ROUTER_V1). const COMMAND_NAMES = { - 0x00: "V3 Swap (Exact In)", - 0x01: "V3 Swap (Exact Out)", - 0x02: "Permit2 Transfer", - 0x03: "Permit2 Permit Batch", - 0x04: "Sweep", - 0x05: "Transfer", - 0x06: "Pay Portion", - 0x07: "Pay Portion (Full Precision)", - 0x08: "V2 Swap (Exact In)", - 0x09: "V2 Swap (Exact Out)", - 0x0a: "Permit2 Permit", - 0x0b: "Wrap ETH", - 0x0c: "Unwrap WETH", - 0x0d: "Permit2 Transfer Batch", - 0x0e: "Balance Check", - 0x10: "V4 Swap", - 0x11: "V3 Position Mgr Permit", - 0x12: "V3 Position Mgr Call", - 0x13: "V4 Initialize Pool", - 0x14: "V4 Position Mgr Call", - 0x21: "Execute Sub-Plan", - 0x40: "Across Bridge Deposit", + V3_SWAP_EXACT_IN: "V3 Swap (Exact In)", + V3_SWAP_EXACT_OUT: "V3 Swap (Exact Out)", + PERMIT2_TRANSFER_FROM: "Permit2 Transfer", + PERMIT2_PERMIT_BATCH: "Permit2 Permit Batch", + SWEEP: "Sweep", + TRANSFER: "Transfer", + PAY_PORTION: "Pay Portion", + PAY_PORTION_FULL_PRECISION: "Pay Portion (Full Precision)", + V2_SWAP_EXACT_IN: "V2 Swap (Exact In)", + V2_SWAP_EXACT_OUT: "V2 Swap (Exact Out)", + PERMIT2_PERMIT: "Permit2 Permit", + WRAP_ETH: "Wrap ETH", + UNWRAP_WETH: "Unwrap WETH", + PERMIT2_TRANSFER_FROM_BATCH: "Permit2 Transfer Batch", + BALANCE_CHECK_ERC20: "Balance Check", + V4_SWAP: "V4 Swap", + V3_POSITION_MANAGER_PERMIT: "V3 Position Mgr Permit", + V3_POSITION_MANAGER_CALL: "V3 Position Mgr Call", + V4_INITIALIZE_POOL: "V4 Initialize Pool", + V4_POSITION_MANAGER_CALL: "V4 Position Mgr Call", + EXECUTE_SUB_PLAN: "Execute Sub-Plan", + ACROSS_V4_DEPOSIT_V3: "Across Bridge Deposit", + // The NFT commands of UniversalRouterV1 and UniversalRouterV1_2_V2Support, + // and the latter's APPROVE_ERC20. + SEAPORT: "Seaport", + SEAPORT_V1_4: "Seaport V1.4", + SEAPORT_V1_5: "Seaport V1.5", + LOOKS_RARE_721: "LooksRare 721", + LOOKS_RARE_1155: "LooksRare 1155", + LOOKS_RARE_V2: "LooksRare V2", + NFTX: "NFTX", + CRYPTOPUNKS: "CryptoPunks", + OWNER_CHECK_721: "Owner Check 721", + OWNER_CHECK_1155: "Owner Check 1155", + SWEEP_ERC721: "Sweep ERC-721", + SWEEP_ERC1155: "Sweep ERC-1155", + X2Y2_721: "X2Y2 721", + X2Y2_1155: "X2Y2 1155", + SUDOSWAP: "Sudoswap", + NFT20: "NFT20", + FOUNDATION: "Foundation", + ELEMENT_MARKET: "Element Market", + APPROVE_ERC20: "Approve ERC-20", }; // The swap's Amount and Min. received lines land on the same approval screen, @@ -913,7 +1026,7 @@ function decodeV4Swap(input) { function decode(data, toAddress, chainId, sources, fromAddress) { const release = routerRelease(toAddress, chainId); if (release === null) return null; - const { commandTypeMask, allowRevert } = release; + const { commandTypeMask, commands, allowRevert } = release; try { const parsed = ROUTER_IFACE.parseTransaction({ data }); if (!parsed) return null; @@ -1026,7 +1139,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // router adds only what it holds. const isHop = (i, token, recipient) => { if (i + 1 >= commandsBytes.length) return false; - if ((commandsBytes[i + 1] & commandTypeMask) !== 0x08) return false; + const nextCommand = + commands[commandsBytes[i + 1] & commandTypeMask]; + if (nextCommand !== "V2_SWAP_EXACT_IN") return false; const next = decodeV2SwapExactIn(inputs[i + 1]); return ( next !== null && @@ -1042,23 +1157,22 @@ function decode(data, toAddress, chainId, sources, fromAddress) { for (let i = 0; i < commandsBytes.length; i++) { const cmdId = commandsBytes[i] & commandTypeMask; + // The command this release runs for the step (see ROUTER_V1), + // undefined for a type it names no command for. + const command = commands[cmdId]; // The router goes on to the next step if this one fails (see // ROUTER_V1). The flag on any other command has no effect. const allowedToFail = (commandsBytes[i] & ALLOW_REVERT_FLAG) !== 0 && allowRevert.includes(cmdId); - // Only the routers with the 0x7f mask have command 0x07 (see - // COMMAND_NAMES). - const name = - cmdId === 0x07 && commandTypeMask !== 0x7f - ? null - : COMMAND_NAMES[cmdId]; commandNames.push( - name || "Command 0x" + cmdId.toString(16).padStart(2, "0"), + present(command) + ? COMMAND_NAMES[command] + : "Command 0x" + cmdId.toString(16).padStart(2, "0"), ); try { - if (cmdId === 0x21) { + if (command === "EXECUTE_SUB_PLAN") { // 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. @@ -1066,13 +1180,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) { hasSubPlan = true; commandNames[i] += " (its steps are not shown)"; payeeUnread = true; - } else if (cmdId === 0x0a) { + } else if (command === "PERMIT2_PERMIT") { const p = decodePermit2(inputs[i]); // A permit states both halves itself, so it may replace an // input side an earlier hop established without breaking // the invariant. if (p) setInput(p.token, p.amount); - } else if (cmdId === 0x02) { + } else if (command === "PERMIT2_TRANSFER_FROM") { const p = decodePermit2TransferFrom(inputs[i]); if (!p) { payeeUnread = true; @@ -1091,7 +1205,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } else if (!isHop(i, p.token, p.recipient)) { addPayee(p.recipient); } - } else if (cmdId === 0x0e) { + } else if (command === "BALANCE_CHECK_ERC20") { const b = decodeBalanceCheck(inputs[i]); if (!b) { // The router still runs a check whose input does not @@ -1121,7 +1235,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { setOutput(b.token, minBalance); } } - } else if (cmdId === 0x00) { + } else if (command === "V3_SWAP_EXACT_IN") { const s = decodeV3SwapExactIn(inputs[i]); if (s) { setInputOnce(s.tokenIn, s.amountIn); @@ -1135,7 +1249,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } else { payeeUnread = true; } - } else if (cmdId === 0x08) { + } else if (command === "V2_SWAP_EXACT_IN") { const s = decodeV2SwapExactIn(inputs[i]); if (s) { setInputOnce(s.tokenIn, s.amountIn); @@ -1146,7 +1260,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } else { payeeUnread = true; } - } else if (cmdId === 0x09) { + } else if (command === "V2_SWAP_EXACT_OUT") { // Buys exactly amountOut and spends at most amountInMax. hasV2ExactOut = true; const s = decodeV2SwapExactOut(inputs[i]); @@ -1159,7 +1273,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } else { payeeUnread = true; } - } else if (cmdId === 0x04) { + } else if (command === "SWEEP") { const s = decodeSweep(inputs[i]); if (s) { addPayee(s.recipient); @@ -1170,9 +1284,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) { payeeUnread = true; } } else if ( - cmdId === 0x05 || - cmdId === 0x06 || - (cmdId === 0x07 && commandTypeMask === 0x7f) + command === "TRANSFER" || + command === "PAY_PORTION" || + command === "PAY_PORTION_FULL_PRECISION" ) { const t = decodeTransfer(inputs[i]); if (t) { @@ -1183,16 +1297,16 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // PAY_PORTION_FULL_PRECISION pays all the router // holds of the token, as SWEEP does. const all = - cmdId === 0x05 + command === "TRANSFER" ? CONTRACT_BALANCE - : cmdId === 0x06 + : command === "PAY_PORTION" ? 10000n : 10n ** 18n; // A TRANSFER of ETH has no such amount: the router // sends ETH as written, so a TRANSFER of // CONTRACT_BALANCE of ETH always reverts. const ethTransfer = - cmdId === 0x05 && + command === "TRANSFER" && t.token === "0x0000000000000000000000000000000000000000"; if ( @@ -1205,9 +1319,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } else { payeeUnread = true; } - } else if (cmdId === 0x40) { - // Only the routers with the 0x7f mask read a command type - // of 0x40. + } else if (command === "ACROSS_V4_DEPOSIT_V3") { const a = decodeAcrossV4DepositV3(inputs[i]); if (a) { bridgeDeposit = true; @@ -1230,7 +1342,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } else { payeeUnread = true; } - } else if (cmdId === 0x0b) { + } else if (command === "WRAP_ETH") { const w = decodeWrapEth(inputs[i]); if (w) { setInputOnce( @@ -1241,17 +1353,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } else { payeeUnread = true; } - } else if (cmdId === 0x10) { - let v4 = decodeV4Swap(inputs[i]); - // A step the router lets fail guarantees nothing, so a - // minimum it states is read as zero, as a - // BALANCE_CHECK_ERC20's is. Only UniversalRouterV1 and - // UniversalRouterV1_2_V2Support let this command fail; on - // them it is an NFT market call, still read here as a V4 - // swap (https://git.eeqj.de/sneak/AutistMask/issues/592). - if (v4 && allowedToFail && present(v4.amountOutMin)) { - v4 = { ...v4, amountOutMin: 0n }; - } + } else if (command === "V4_SWAP") { + const v4 = decodeV4Swap(inputs[i]); if (v4) { for (const t of v4.takes) { if (!isHop(i, t.currency, t.recipient)) { @@ -1292,7 +1395,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { setInputOnce(v4.tokenIn, v4.amountIn); setOutput(v4.tokenOut, v4.amountOutMin); } - } else if (cmdId === 0x0c) { + } else if (command === "UNWRAP_WETH") { hasUnwrapWeth = true; const u = decodeUnwrapWeth(inputs[i]); if (u) { @@ -1306,9 +1409,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) { payeeUnread = true; } } else { - // Any other command may pay an address this decoder does - // not read: V3_SWAP_EXACT_OUT and - // PERMIT2_TRANSFER_FROM_BATCH among them. + // Any other step may pay an address this decoder does not + // read: V3_SWAP_EXACT_OUT, PERMIT2_TRANSFER_FROM_BATCH and + // the NFT commands among them, and a type the release + // names no command for. payeeUnread = true; } } catch { diff --git a/tests/uniswapRecipient.test.js b/tests/uniswapRecipient.test.js index 9295b56..75a21ca 100644 --- a/tests/uniswapRecipient.test.js +++ b/tests/uniswapRecipient.test.js @@ -2,7 +2,9 @@ // (https://git.eeqj.de/sneak/AutistMask/issues/594 and // https://git.eeqj.de/sneak/AutistMask/issues/599), the allow-revert flag // on the steps that pay it -// (https://git.eeqj.de/sneak/AutistMask/issues/602), and the address whose +// (https://git.eeqj.de/sneak/AutistMask/issues/602), the command each router +// release runs for a command type +// (https://git.eeqj.de/sneak/AutistMask/issues/592), and the address whose // balance a BALANCE_CHECK_ERC20 checks // (https://git.eeqj.de/sneak/AutistMask/issues/600). // @@ -1570,32 +1572,6 @@ describe("the allow-revert flag", () => { ); }); - // On these two routers 0x10 is an NFT market call the router lets fail, - // still read as a V4 swap - // (https://git.eeqj.de/sneak/AutistMask/issues/592). - test.each([ - { router: "UniversalRouterV1", address: ROUTER_V1 }, - { router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2 }, - ])( - "on $router, a V4_SWAP with the flag is allowed to fail and states no minimum", - ({ address }) => { - const step = v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, takeAll(USDC)); - - const plain = decode(execute(step), USER, address); - expect(detail(plain, "Steps").value).toBe("V4 Swap"); - expect(detail(plain, "Min. received").value).toBe("1990.0000 USDC"); - - const withFlag = decode(execute(flagged(step)), USER, address); - expect(detail(withFlag, "Steps").value).toBe( - "V4 Swap (allowed to fail)", - ); - expect(detail(withFlag, "Token Out").address).toBe(USDC); - expect(detail(withFlag, "Min. received").value).toBe( - "None (no minimum guaranteed)", - ); - }, - ); - // Only the routers from UniversalRouterV2 on let a PERMIT2_PERMIT fail. test.each([ { router: "UniversalRouterV1", address: ROUTER_V1, mark: "" }, @@ -1663,14 +1639,14 @@ describe("the allow-revert flag", () => { address: ROUTER_V1, command: "FOUNDATION", id: 0x1c, - name: "Command 0x1c", + name: "Foundation", }, { router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2, command: "ELEMENT_MARKET", id: 0x1e, - name: "Command 0x1e", + name: "Element Market", }, { router: "UniversalRouterV2", @@ -1718,6 +1694,305 @@ describe("the allow-revert flag", () => { ); }); +// https://git.eeqj.de/sneak/AutistMask/issues/592: each release runs its own +// command for a command type, as its Commands.sol names it. The decoder named +// and read every type as UniversalRouterV2 does, so on the two older routers +// an NFT command read as a V4 swap, and on UniversalRouterV1, which reverts on +// 0x0e, that type read as a balance check that set the minimum. +describe("the command each release runs for a command type", () => { + // Inputs UniversalRouterV2 reads as a V4 swap of 2,000 USDT for at least + // 1,990 USDC paid to the user, and as a check that the user holds at + // least 1 WETH. + const swapInput = v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, takeAll(USDC))[1]; + const checkInput = balanceCheck(WETH, ONE_WETH)[1]; + // The lines of a transaction whose one step the decoder does not read. + const NOT_READ = [ + "Protocol", + "Token In", + "Token Out", + "Paid to", + "Steps", + "Deadline", + ]; + + test.each([ + { + router: "UniversalRouterV1", + address: ROUTER_V1, + id: 0x0e, + input: checkInput, + name: "Command 0x0e", + }, + { + router: "UniversalRouterV1", + address: ROUTER_V1, + id: 0x10, + input: swapInput, + name: "Seaport", + }, + { + router: "UniversalRouterV1", + address: ROUTER_V1, + id: 0x11, + input: swapInput, + name: "LooksRare 721", + }, + { + router: "UniversalRouterV1", + address: ROUTER_V1, + id: 0x12, + input: swapInput, + name: "NFTX", + }, + { + router: "UniversalRouterV1", + address: ROUTER_V1, + id: 0x13, + input: swapInput, + name: "CryptoPunks", + }, + { + router: "UniversalRouterV1", + address: ROUTER_V1, + id: 0x14, + input: swapInput, + name: "LooksRare 1155", + }, + { + router: "UniversalRouterV1", + address: ROUTER_V1, + id: 0x15, + input: swapInput, + name: "Owner Check 721", + }, + { + router: "UniversalRouterV1", + address: ROUTER_V1, + id: 0x16, + input: swapInput, + name: "Owner Check 1155", + }, + { + router: "UniversalRouterV1", + address: ROUTER_V1, + id: 0x17, + input: swapInput, + name: "Sweep ERC-721", + }, + { + router: "UniversalRouterV1", + address: ROUTER_V1, + id: 0x18, + input: swapInput, + name: "X2Y2 721", + }, + { + router: "UniversalRouterV1", + address: ROUTER_V1, + id: 0x19, + input: swapInput, + name: "Sudoswap", + }, + { + router: "UniversalRouterV1", + address: ROUTER_V1, + id: 0x1a, + input: swapInput, + name: "NFT20", + }, + { + router: "UniversalRouterV1", + address: ROUTER_V1, + id: 0x1b, + input: swapInput, + name: "X2Y2 1155", + }, + { + router: "UniversalRouterV1", + address: ROUTER_V1, + id: 0x1c, + input: swapInput, + name: "Foundation", + }, + { + router: "UniversalRouterV1", + address: ROUTER_V1, + id: 0x1d, + input: swapInput, + name: "Sweep ERC-1155", + }, + { + router: "UniversalRouterV1_2_V2Support", + address: ROUTER_V1_2, + id: 0x10, + input: swapInput, + name: "Seaport V1.5", + }, + { + router: "UniversalRouterV1_2_V2Support", + address: ROUTER_V1_2, + id: 0x11, + input: swapInput, + name: "LooksRare V2", + }, + { + router: "UniversalRouterV1_2_V2Support", + address: ROUTER_V1_2, + id: 0x12, + input: swapInput, + name: "NFTX", + }, + { + router: "UniversalRouterV1_2_V2Support", + address: ROUTER_V1_2, + id: 0x13, + input: swapInput, + name: "CryptoPunks", + }, + { + router: "UniversalRouterV1_2_V2Support", + address: ROUTER_V1_2, + id: 0x14, + input: swapInput, + name: "Command 0x14", + }, + { + router: "UniversalRouterV1_2_V2Support", + address: ROUTER_V1_2, + id: 0x15, + input: swapInput, + name: "Owner Check 721", + }, + { + router: "UniversalRouterV1_2_V2Support", + address: ROUTER_V1_2, + id: 0x16, + input: swapInput, + name: "Owner Check 1155", + }, + { + router: "UniversalRouterV1_2_V2Support", + address: ROUTER_V1_2, + id: 0x17, + input: swapInput, + name: "Sweep ERC-721", + }, + { + router: "UniversalRouterV1_2_V2Support", + address: ROUTER_V1_2, + id: 0x18, + input: swapInput, + name: "X2Y2 721", + }, + { + router: "UniversalRouterV1_2_V2Support", + address: ROUTER_V1_2, + id: 0x19, + input: swapInput, + name: "Sudoswap", + }, + { + router: "UniversalRouterV1_2_V2Support", + address: ROUTER_V1_2, + id: 0x1a, + input: swapInput, + name: "NFT20", + }, + { + router: "UniversalRouterV1_2_V2Support", + address: ROUTER_V1_2, + id: 0x1b, + input: swapInput, + name: "X2Y2 1155", + }, + { + router: "UniversalRouterV1_2_V2Support", + address: ROUTER_V1_2, + id: 0x1c, + input: swapInput, + name: "Foundation", + }, + { + router: "UniversalRouterV1_2_V2Support", + address: ROUTER_V1_2, + id: 0x1d, + input: swapInput, + name: "Sweep ERC-1155", + }, + { + router: "UniversalRouterV1_2_V2Support", + address: ROUTER_V1_2, + id: 0x1e, + input: swapInput, + name: "Element Market", + }, + { + router: "UniversalRouterV1_2_V2Support", + address: ROUTER_V1_2, + id: 0x20, + input: swapInput, + name: "Seaport V1.4", + }, + { + router: "UniversalRouterV1_2_V2Support", + address: ROUTER_V1_2, + id: 0x22, + input: swapInput, + name: "Approve ERC-20", + }, + { + router: "UniversalRouterV2", + address: ROUTER, + id: 0x11, + input: swapInput, + name: "V3 Position Mgr Permit", + }, + { + router: "UniversalRouterV2", + address: ROUTER, + id: 0x12, + input: swapInput, + name: "V3 Position Mgr Call", + }, + { + router: "UniversalRouterV2", + address: ROUTER, + id: 0x13, + input: swapInput, + name: "V4 Initialize Pool", + }, + ])( + "on $router, '$name' is a step the decoder does not read", + ({ address, id, input, name }) => { + const result = decode(execute([id, input]), USER, address); + expect(detail(result, "Steps").value).toBe(name); + expect(result.name).toBe("Uniswap Swap"); + expect(labels(result)).toEqual(NOT_READ); + expect(paidTo(result)).toEqual([UNDECODED]); + }, + ); + + test.each([ + { router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2 }, + { router: "UniversalRouterV2", address: ROUTER }, + ])( + "on $router, 0x0e is a balance check, which sets the minimum", + ({ address }) => { + const result = decode(execute([0x0e, checkInput]), USER, address); + expect(detail(result, "Steps").value).toBe("Balance Check"); + expect(detail(result, "Token Out").address).toBe(WETH); + expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH"); + }, + ); + + test("on UniversalRouterV2, 0x10 is a V4 swap", () => { + const result = decode(execute([0x10, swapInput]), USER, ROUTER); + expect(detail(result, "Steps").value).toBe("V4 Swap"); + expect(result.name).toBe("Swap USDT → USDC"); + expect(detail(result, "Min. received").value).toBe("1990.0000 USDC"); + }); +}); + // https://git.eeqj.de/sneak/AutistMask/issues/600: on every release that has // BALANCE_CHECK_ERC20, the router checks the balance of the owner as written, // without map(), so address(1) and address(2) there are those addresses, not