From c99772a520012d07110ad0df0b7a363d02babbab Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 19 Oct 2026 15:24:13 +0200 Subject: [PATCH] fix: a step the router lets fail is marked and sets no minimum (closes #602) Each Universal Router release now carries the command types its dispatch() can return a failure for instead of reverting, read from its source; only a step of one of those with the allow-revert flag (0x80) set can fail while the transaction goes on. Such a step is listed with "(allowed to fail)". A minimum such a step states guarantees nothing, so it is read as zero: a BALANCE_CHECK_ERC20 then keeps an earlier step's minimum, and 0x10 on UniversalRouterV1 and UniversalRouterV1_2_V2Support, an NFT call there still read as a V4 swap, states no minimum. Every swap and every payment of ETH or an ERC-20 token succeeds or reverts the whole transaction, so the flag on them has no effect. Model: opus-5-5 --- README.md | 40 +++- src/shared/uniswap.js | 141 +++++++++---- tests/uniswap.test.js | 22 +- tests/uniswapRecipient.test.js | 354 ++++++++++++++++++++++++++++++++- 4 files changed, 505 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index f86cc21..db9fc7b 100644 --- a/README.md +++ b/README.md @@ -1198,7 +1198,11 @@ number. They can read: guarantees nothing: it sets `Token Out` and `Min. received` only when the output side holds no minimum, not even a zero one, at the point the check is reached, and otherwise leaves the current token and figure in place. A nonzero - `minBalance` sets both lines, as a swap step does. + `minBalance` sets both lines, as a swap step does. A `BALANCE_CHECK_ERC20` + step that the router lets fail without reverting the transaction (see + Transaction Decoding) guarantees nothing either, whatever its `minBalance`, + and is read as one whose `minBalance` is zero. A minimum stated by a V4 swap + step the router lets fail is likewise read as zero. 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 @@ -2970,13 +2974,33 @@ Currently supported: 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. 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 is listed - there as `Command 0x07`. 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 + (`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`, + `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` + and `EXECUTE_SUB_PLAN`; on `UniversalRouterV1_2_V2Support`, + `BALANCE_CHECK_ERC20`, `EXECUTE_SUB_PLAN` and its NFT market calls and NFT + owner checks; on `UniversalRouterV1`, its NFT market calls and NFT owner + checks. Such a step may then fail while the transaction goes on to the next + step, and it is listed with ` (allowed to fail)` after its name, for example + `Balance Check (allowed to fail)`. On every other command, among them each V2, + 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 + 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 + is listed there as `Command 0x07`. 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` diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index abae1ea..787d2ba 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -24,55 +24,104 @@ const ROUTER_IFACE = new Interface([ "function execute(bytes commands, bytes[] inputs, uint256 deadline)", ]); -// 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. +// The Universal Router releases deployed at the addresses below, each with two +// things read from its source at the commit linked for it: // -// 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 +// - `commandTypeMask`, the mask it 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 its addresses. +// - `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 +// the whole transaction when a step fails, unless the step's command byte +// has the allow-revert flag set: then it goes on to the next step. Only for +// these command types does `dispatch()` return a failure. Every other +// command, among them each V2, V3 and V4 swap and each payment of ETH or an +// ERC-20 token, succeeds or reverts the whole transaction itself, so the +// flag has no effect on it. +// +// UniversalRouterV1: https://github.com/Uniswap/universal-router/tree/cda697da221e24ca143bc4f55023694a2a16796f/contracts +const ROUTER_V1 = { + commandTypeMask: 0x1f, + // Its NFT market calls (0x10 to 0x14, 0x18 to 0x1c) and NFT owner checks + // (0x15, 0x16). + allowRevert: [ + 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x18, 0x19, 0x1a, 0x1b, 0x1c, + ], +}; +// UniversalRouterV1_2_V2Support: https://github.com/Uniswap/universal-router/tree/41183d6eb154f0ab0e74a0e911a5ef9ea51fc4bd/contracts +const ROUTER_V1_2 = { + commandTypeMask: 0x3f, + // 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). + allowRevert: [ + 0x0e, 0x10, 0x11, 0x12, 0x13, 0x15, 0x16, 0x18, 0x19, 0x1a, 0x1b, 0x1c, + 0x1e, 0x20, 0x21, + ], +}; +// UniversalRouterV2: https://github.com/Uniswap/universal-router/tree/3663f6db6e2fe121753cd2d899699c2dc75dca86/contracts +const ROUTER_V2 = { + commandTypeMask: 0x3f, + // 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 + // EXECUTE_SUB_PLAN (0x21). + allowRevert: [0x03, 0x0a, 0x0e, 0x11, 0x12, 0x13, 0x14, 0x21], +}; +// UniversalRouterV2_1_1: https://github.com/Uniswap/universal-router/tree/999d561c3ad58fb5cab91b602911f3c75591a9c7/contracts +// UniversalRouterV2_1_2: https://github.com/Uniswap/universal-router/tree/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/contracts +// The three files are the same in both releases. +const ROUTER_V2_1 = { + commandTypeMask: 0x7f, + // The same as UniversalRouterV2's. + allowRevert: ROUTER_V2.allowRevert, +}; + +// The Universal Router deployments on each network, by chain id, copied from +// the deployment files Uniswap publishes, each address named by its key there +// and given with the release it runs. The files also list +// `UnsupportedProtocol`, which is not a router. const ROUTER_ADDRESSES = new Map([ [ NETWORKS.mainnet.chainId, // https://github.com/Uniswap/universal-router/blob/543e1a19d6e21e31ced2512eec5792b50f13a0ba/deploy-addresses/mainnet.json [ - ["0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B", 0x1f], // UniversalRouterV1 - ["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", 0x3f], // UniversalRouterV1_2_V2Support - ["0x66a9893cc07d91d95644aedd05d03f95e1dba8af", 0x3f], // UniversalRouterV2 - ["0x4C82D1fBFe28C977cBB58D8C7FF8FCF9F70a2cCA", 0x7f], // UniversalRouterV2_1_1 - ["0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85", 0x7f], // UniversalRouterV2_1_2 + ["0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B", ROUTER_V1], // UniversalRouterV1 + ["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", ROUTER_V1_2], // UniversalRouterV1_2_V2Support + ["0x66a9893cc07d91d95644aedd05d03f95e1dba8af", ROUTER_V2], // UniversalRouterV2 + ["0x4C82D1fBFe28C977cBB58D8C7FF8FCF9F70a2cCA", ROUTER_V2_1], // UniversalRouterV2_1_1 + ["0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85", ROUTER_V2_1], // UniversalRouterV2_1_2 ], ], [ NETWORKS.sepolia.chainId, // https://github.com/Uniswap/universal-router/blob/543e1a19d6e21e31ced2512eec5792b50f13a0ba/deploy-addresses/sepolia.json [ - ["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", 0x3f], // UniversalRouterV1_2_V2Support - ["0x3a9d48ab9751398bbfa63ad67599bb04e4bdf98b", 0x3f], // UniversalRouterV2 - ["0x7DfD4F31be6814D2906BDE155c3e1B146EAc1468", 0x7f], // UniversalRouterV2_1_1 - ["0x7E4f6c5e954Da5c61B3423D81E2277431Ac043f3", 0x7f], // UniversalRouterV2_1_2 + ["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", ROUTER_V1_2], // UniversalRouterV1_2_V2Support + ["0x3a9d48ab9751398bbfa63ad67599bb04e4bdf98b", ROUTER_V2], // UniversalRouterV2 + ["0x7DfD4F31be6814D2906BDE155c3e1B146EAc1468", ROUTER_V2_1], // UniversalRouterV2_1_1 + ["0x7E4f6c5e954Da5c61B3423D81E2277431Ac043f3", ROUTER_V2_1], // UniversalRouterV2_1_2 ], ], ]); -// 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) { +// The release that the Universal Router deployment at `address` on the +// network with this chain id runs, or null when `address` is not one of them. +// Address case is ignored. +function routerRelease(address, chainId) { const routers = ROUTER_ADDRESSES.get(chainId) || []; const wanted = (address || "").toLowerCase(); const router = routers.find(([a]) => a.toLowerCase() === wanted); return router ? router[1] : null; } +// The allow-revert flag on a command byte (see ROUTER_V1). +const ALLOW_REVERT_FLAG = 0x80; + // The Uniswap V2 factory and pair init code hash the Universal Routers on each // network were deployed with. A V2 swap step swaps through the pair its path // starts at, which the router finds as `UniswapV2Library.pairFor()` does: the @@ -123,7 +172,7 @@ function v2PairFor(chainId, tokenA, tokenB) { } // Universal Router command types, each read from its command byte with the -// router's mask (see ROUTER_ADDRESSES). Only the routers with the 0x7f mask +// 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. @@ -856,8 +905,9 @@ 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, fromAddress) { - const commandTypeMask = routerCommandTypeMask(toAddress, chainId); - if (commandTypeMask === null) return null; + const release = routerRelease(toAddress, chainId); + if (release === null) return null; + const { commandTypeMask, allowRevert } = release; try { const parsed = ROUTER_IFACE.parseTransaction({ data }); if (!parsed) return null; @@ -986,6 +1036,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) { for (let i = 0; i < commandsBytes.length; i++) { const cmdId = commandsBytes[i] & commandTypeMask; + // 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 = @@ -1034,11 +1089,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) { const b = decodeBalanceCheck(inputs[i]); // The router passes this check whenever the owner holds at // least minBalance, so a zero one guarantees nothing and - // does not replace a minimum an earlier step stated. Any - // other minBalance sets the output side as a swap does. - // It pays nobody. - if (b && !(b.minBalance === 0n && present(minOutput))) { - setOutput(b.token, b.minBalance); + // does not replace a minimum an earlier step stated. A + // check the router lets fail guarantees nothing either, so + // it is read as a zero one. Any other minBalance sets the + // output side as a swap does. It pays nobody. + const minBalance = b && !allowedToFail ? b.minBalance : 0n; + if (b && !(minBalance === 0n && present(minOutput))) { + setOutput(b.token, minBalance); } } else if (cmdId === 0x00) { const s = decodeV3SwapExactIn(inputs[i]); @@ -1161,7 +1218,16 @@ function decode(data, toAddress, chainId, sources, fromAddress) { payeeUnread = true; } } else if (cmdId === 0x10) { - const v4 = decodeV4Swap(inputs[i]); + 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 }; + } if (v4) { for (const t of v4.takes) { if (!isHop(i, t.currency, t.recipient)) { @@ -1225,6 +1291,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // Skip commands we can't decode. One may pay any address. payeeUnread = true; } + if (allowedToFail) { + commandNames[i] += " (allowed to fail)"; + } } // The commands of an EXECUTE_SUB_PLAN step may spend the user's tokens diff --git a/tests/uniswap.test.js b/tests/uniswap.test.js index c2fdcf4..9ad6802 100644 --- a/tests/uniswap.test.js +++ b/tests/uniswap.test.js @@ -987,13 +987,23 @@ describe("uniswap decoder", () => { ); // 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. + // not read. With the allow-revert flag (0x80) the command byte is 0xa1, + // and the router lets the step fail + // (https://git.eeqj.de/sneak/AutistMask/issues/602). test.each([ - { flag: "without", command: 0x21 }, - { flag: "with", command: 0xa1 }, + { + flag: "without", + command: 0x21, + steps: "Execute Sub-Plan (its steps are not shown)", + }, + { + flag: "with", + command: 0xa1, + steps: "Execute Sub-Plan (its steps are not shown) (allowed to fail)", + }, ])( "an EXECUTE_SUB_PLAN step $flag the allow-revert flag says its steps are not shown", - ({ command }) => { + ({ command, steps }) => { const subPlan = encodeSubPlan("0x00", [ encodeV3SwapExactIn(USER_ADDR, 2000000n, ONE_WETH, [ USDT_ADDR, @@ -1013,9 +1023,7 @@ describe("uniswap decoder", () => { USER_ADDR, ); expect(result.name).toBe("Uniswap Swap"); - expect(detail(result, "Steps").value).toBe( - "Execute Sub-Plan (its steps are not shown)", - ); + expect(detail(result, "Steps").value).toBe(steps); // The sub-plan names both currencies, so the lines must not say // the calldata did not. expect(detail(result, "Token In").value).toBe( diff --git a/tests/uniswapRecipient.test.js b/tests/uniswapRecipient.test.js index 946f37d..c1bebdb 100644 --- a/tests/uniswapRecipient.test.js +++ b/tests/uniswapRecipient.test.js @@ -1,6 +1,8 @@ // The address a swap's output is paid to // (https://git.eeqj.de/sneak/AutistMask/issues/594 and -// https://git.eeqj.de/sneak/AutistMask/issues/599). +// https://git.eeqj.de/sneak/AutistMask/issues/599), and the allow-revert flag +// on the steps that pay it +// (https://git.eeqj.de/sneak/AutistMask/issues/602). // // Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH, TRANSFER, PAY_PORTION, // PAY_PORTION_FULL_PRECISION, PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 and @@ -149,6 +151,20 @@ function transfer(command, token, recipient, amount) { ]; } +// PERMIT2_PERMIT (0x0a) of `amount` of `token`, for the router to spend. +function permit2Permit(token, amount) { + return [ + 0x0a, + coder.encode( + [ + "tuple(tuple(address,uint160,uint48,uint48),address,uint256)", + "bytes", + ], + [[[token, amount, 0, 0], ROUTER, 0n], "0x"], + ), + ]; +} + function permit2TransferFrom(token, recipient, amount) { return [ 0x02, @@ -1362,6 +1378,342 @@ describe("paths whose recipients were untested", () => { }); }); +// https://git.eeqj.de/sneak/AutistMask/issues/602: the top bit of a command +// byte (0x80) is the allow-revert flag. The router lets a step with it fail +// and goes on to the next step only for some commands, none of them a swap or +// a payment; on every other command the flag has no effect. +describe("the allow-revert flag", () => { + // UniversalRouterV1_2_V2Support on Ethereum Mainnet. + const ROUTER_V1_2 = "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD"; + // UniversalRouterV2_1_1 on Ethereum Mainnet. + const ROUTER_V2_1_1 = "0x4C82D1fBFe28C977cBB58D8C7FF8FCF9F70a2cCA"; + // The WETH/USDC pair on Ethereum Mainnet, as its V2 factory reports it. + const PAIR = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc"; + + // `step` with the allow-revert flag set on its command byte. + const flagged = ([command, input]) => [command | 0x80, input]; + // `steps` with the flag set on the one at `index`. + const flagStep = (steps, index) => + steps.map((s, i) => (i === index ? flagged(s) : s)); + + const usdcToRouter = v3SwapExactIn( + ADDRESS_THIS, + USDT_2000, + USDC_1990, + USDT, + USDC, + ); + const wethToPair = v3SwapExactIn(PAIR, USDT_2000, 0n, USDT, WETH); + const pairSpends = v2Swap( + 0x08, + MSG_SENDER, + 0n, + USDC_1990, + [WETH, USDC], + false, + ); + + // The flag is set on the step at `index`: a swap, a payment, or the V2 + // swap that a payment to its pair hops into. + test.each([ + { + kind: "V2_SWAP_EXACT_IN", + steps: [ + v2Swap(0x08, MSG_SENDER, USDT_2000, ONE_WETH, [USDT, WETH]), + ], + index: 0, + }, + { + kind: "V2_SWAP_EXACT_OUT", + steps: [ + v2Swap(0x09, MSG_SENDER, ONE_WETH, USDT_2000, [USDT, WETH]), + ], + index: 0, + }, + { + kind: "V3_SWAP_EXACT_IN", + steps: [swapToWeth(MSG_SENDER)], + index: 0, + }, + { + kind: "V4_SWAP", + steps: [v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, takeAll(USDC))], + index: 0, + }, + { + kind: "SWEEP", + steps: [usdcToRouter, sweep(USDC, MSG_SENDER)], + index: 1, + }, + { + kind: "TRANSFER", + steps: [ + usdcToRouter, + transfer(0x05, USDC, MSG_SENDER, CONTRACT_BALANCE), + ], + index: 1, + }, + { + kind: "PAY_PORTION", + steps: [usdcToRouter, transfer(0x06, USDC, MSG_SENDER, 10000n)], + index: 1, + }, + { + kind: "PAY_PORTION_FULL_PRECISION", + steps: [usdcToRouter, transfer(0x07, USDC, MSG_SENDER, 10n ** 18n)], + index: 1, + }, + { + kind: "UNWRAP_WETH", + steps: [swapToWeth(ADDRESS_THIS), unwrapWeth(MSG_SENDER)], + index: 1, + }, + { + kind: "WRAP_ETH", + steps: [ + wrapEth(ADDRESS_THIS, ONE_WETH), + v3SwapExactIn( + MSG_SENDER, + ONE_WETH, + USDC_1990, + WETH, + USDC, + false, + ), + ], + index: 0, + }, + { + kind: "ACROSS_V4_DEPOSIT_V3", + steps: [ + usdcToRouter, + acrossDeposit(USER, USER, USDC, CONTRACT_BALANCE), + ], + index: 1, + }, + { + kind: "PERMIT2_TRANSFER_FROM to the pair the next V2 swap spends", + steps: [permit2TransferFrom(WETH, PAIR, ONE_WETH), pairSpends], + index: 0, + }, + { + kind: "a swap paid to the pair the next V2 swap spends", + steps: [wethToPair, pairSpends], + index: 0, + }, + { + kind: "the V2 swap after a swap paid to its pair", + steps: [wethToPair, pairSpends], + index: 1, + }, + ])( + "$kind reads the same with the flag as without it", + ({ steps, index }) => { + const plain = decode(execute(...steps), USER, ROUTER_V2_1_2); + const withFlag = decode( + execute(...flagStep(steps, index)), + USER, + ROUTER_V2_1_2, + ); + expect(withFlag).toEqual(plain); + expect(detail(withFlag, "Steps").value).not.toContain( + "allowed to fail", + ); + }, + ); + + test.each([ + { router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2 }, + { router: "UniversalRouterV2", address: ROUTER }, + { router: "UniversalRouterV2_1_2", address: ROUTER_V2_1_2 }, + ])( + "on $router, a BALANCE_CHECK_ERC20 with the flag is allowed to fail and does not replace the swap's minimum", + ({ address }) => { + const check = balanceCheck(WETH, 2n * ONE_WETH); + + const plain = decode( + execute(swapToWeth(MSG_SENDER), check), + USER, + address, + ); + expect(detail(plain, "Steps").value).toBe( + "V3 Swap (Exact In) → Balance Check", + ); + expect(detail(plain, "Min. received").value).toBe("2.0000 WETH"); + + const withFlag = decode( + execute(swapToWeth(MSG_SENDER), flagged(check)), + USER, + address, + ); + expect(detail(withFlag, "Steps").value).toBe( + "V3 Swap (Exact In) → Balance Check (allowed to fail)", + ); + expect(detail(withFlag, "Min. received").value).toBe("1.0000 WETH"); + }, + ); + + test("a BALANCE_CHECK_ERC20 with the flag, and no minimum before it, states none", () => { + const check = balanceCheck(WETH, ONE_WETH); + expect(detail(decode(execute(check)), "Min. paid out").value).toBe( + "1.0000 WETH", + ); + + const withFlag = decode(execute(flagged(check))); + expect(detail(withFlag, "Token Out").address).toBe(WETH); + expect(detail(withFlag, "Min. paid out").value).toBe( + "None (no minimum guaranteed)", + ); + }); + + // 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: "" }, + { + router: "UniversalRouterV1_2_V2Support", + address: ROUTER_V1_2, + mark: "", + }, + { + router: "UniversalRouterV2", + address: ROUTER, + mark: " (allowed to fail)", + }, + { + router: "UniversalRouterV2_1_2", + address: ROUTER_V2_1_2, + mark: " (allowed to fail)", + }, + ])( + "on $router, a PERMIT2_PERMIT with the flag is listed as 'Permit2 Permit$mark', and the swap reads as without the flag", + ({ address, mark }) => { + const steps = [ + permit2Permit(USDT, USDT_2000), + swapToWeth(MSG_SENDER), + ]; + const plain = decode(execute(...steps), USER, address); + const withFlag = decode( + execute(...flagStep(steps, 0)), + USER, + address, + ); + expect(detail(withFlag, "Steps").value).toBe( + "Permit2 Permit" + mark + " → V3 Swap (Exact In)", + ); + const withoutSteps = (r) => + r.details.filter((d) => d.label !== "Steps"); + expect(withoutSteps(withFlag)).toEqual(withoutSteps(plain)); + expect(detail(withFlag, "Min. received").value).toBe("1.0000 WETH"); + }, + ); + + test("a step the decoder does not read is marked too", () => { + const positionManagerCall = [0x12, "0x"]; + const plain = decode( + execute(swapToWeth(MSG_SENDER), positionManagerCall), + ); + expect(detail(plain, "Steps").value).toBe( + "V3 Swap (Exact In) → V3 Position Mgr Call", + ); + + const withFlag = decode( + execute(swapToWeth(MSG_SENDER), flagged(positionManagerCall)), + ); + expect(detail(withFlag, "Steps").value).toBe( + "V3 Swap (Exact In) → V3 Position Mgr Call (allowed to fail)", + ); + expect(paidTo(withFlag)).toEqual([UNDECODED]); + }); + + // A command from each router's list, by its name in that release's + // Commands.sol. + test.each([ + { + router: "UniversalRouterV1", + address: ROUTER_V1, + command: "FOUNDATION", + id: 0x1c, + name: "Command 0x1c", + }, + { + router: "UniversalRouterV1_2_V2Support", + address: ROUTER_V1_2, + command: "ELEMENT_MARKET", + id: 0x1e, + name: "Command 0x1e", + }, + { + router: "UniversalRouterV2", + address: ROUTER, + command: "PERMIT2_PERMIT_BATCH", + id: 0x03, + name: "Permit2 Permit Batch", + }, + { + router: "UniversalRouterV2_1_1", + address: ROUTER_V2_1_1, + command: "V4_INITIALIZE_POOL", + id: 0x13, + name: "V4 Initialize Pool", + }, + { + router: "UniversalRouterV2_1_2", + address: ROUTER_V2_1_2, + command: "V4_POSITION_MANAGER_CALL", + id: 0x14, + name: "V4 Position Mgr Call", + }, + ])( + "on $router, $command with the flag is listed as '$name (allowed to fail)'", + ({ address, id, name }) => { + const step = [id, "0x"]; + const plain = decode( + execute(swapToWeth(MSG_SENDER), step), + USER, + address, + ); + expect(detail(plain, "Steps").value).toBe( + "V3 Swap (Exact In) → " + name, + ); + + const withFlag = decode( + execute(swapToWeth(MSG_SENDER), flagged(step)), + USER, + address, + ); + expect(detail(withFlag, "Steps").value).toBe( + "V3 Swap (Exact In) → " + name + " (allowed to fail)", + ); + }, + ); +}); + // The DOM stub both screens are drawn into. function makeElement(id) { const classes = new Set();