From f53b503b158ea625200293a726c07780eab07877 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 21 Oct 2026 01:41:35 +0200 Subject: [PATCH] fix: a PERMIT2_PERMIT_BATCH, and a permit that cannot be read, state what they allow (closes #652) A PERMIT2_PERMIT_BATCH was never read, and a PERMIT2_PERMIT whose input could not be read got no lines, so the approval screen showed nothing of the allowances either grants. Each allowance of a batch now gets the three lines a permit whose amount does not stand gets: Permit2 allowance, Allowance token and Allowance spender. A permit or batch that cannot be read gets one Permit2 allowance line reading Unknown (could not be decoded) and leaves no Amount line. Any batch in the transaction keeps a PERMIT2_PERMIT's amount from standing. A batch pays nobody, so it no longer adds a Paid to line. README Transaction Decoding and the TxApproval element list match. Model: opus-5-5 --- README.md | 104 +++++++++++++++------------ src/shared/uniswap.js | 119 ++++++++++++++++++++++-------- tests/uniswap.test.js | 163 +++++++++++++++++++++++++++++++++++++----- 3 files changed, 292 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index 9c13e18..cce151a 100644 --- a/README.md +++ b/README.md @@ -2428,10 +2428,11 @@ view would leave a wallet one click from deletion. - Decoded action (if calldata is recognized): action name, token details, amounts, what each swap step spends that the `Amount` line does not give, what each `PERMIT2_TRANSFER_FROM` moves and where to, what each - `PERMIT2_PERMIT` whose amount is not the `Amount` figure lets its spender - take, steps, deadline, and unless a swap pays only the From address, a - `Paid to` line for each other address it pays, or saying that a token - stays in the router or who it pays is unknown (see Transaction Decoding) + `PERMIT2_PERMIT` whose amount is not the `Amount` figure, and each + `PERMIT2_PERMIT_BATCH`, lets its spender take, steps, deadline, and unless + a swap pays only the From address, a `Paid to` line for each other address + it pays, or saying that a token stays in the router or who it pays is + unknown (see Transaction Decoding) - From: color dot + full address + etherscan link - Contract: color dot + full address + etherscan link, token symbol label if known; for a contract creation, the same sentence as on WaitTx @@ -3224,38 +3225,39 @@ Currently supported: `Unlimited` included, stands as the `Amount` figure in place of the total, which it does only in the plain shape of Uniswap's own routes: it comes before the step that sets the input side, it is the only `PERMIT2_PERMIT` in the - transaction, its spender is the router, the router may not let it fail, no - step before it takes its token or a token the decoder does not know, and the - decoder reads every step after it in full. Permit2 then lets the router take - no more of the token than that amount, so the permit covers every take of it. - A step the decoder does not read in full may raise what Permit2 lets the - router take, as a `PERMIT2_PERMIT_BATCH` can: a command the decoder does not - read (listed at the end of this section), a step whose input it cannot read, - in whole or in part, and a step it fails on. Every other `PERMIT2_PERMIT`, - before the step that sets the input side or after it, leaves `Token In` and - the `Amount` line as they read with no permit: a permit of 5,000 DAI to - another address and then a V3 exact-in of 600 USDT read `Swap USDT → USDC` - with `Amount 600.0000 USDT`, and so do that exact-in and then a permit of - 5,000 DAI to the router. When a permit's amount stands and the step that sets - the input side spends a token other than the permit's, or one the decoder does - not know, that step's amount gets lines of its own, unless it states no amount - and the user pays it none of its input token. Each take the total does not - count gives three lines after the `Amount` line (after `Token In` when there - is none): one of a token other than `Token In`; one of `Token In` after an - amount that is not a quantity (a V4 open delta, `ALREADY_PAID`, which keep - their wording on the `Amount` line, or no stated amount, as of a V4 exact-out - step); and one whose amount the decoder does not know. When the step that sets - the input side states no amount, the user pays it some of its input token, and - there is no `Amount` line, that step gets them too whenever another take's - lines give a figure, or a `PERMIT2_TRANSFER_FROM` of its token gives one, on - the lines below or, into the router, on its `Paid to` line, so that figure - cannot read as all that leaves. When it states no amount and the user pays it - none of its input token, as when the router pays it, that step takes none of - that token from the user's wallet and never gets them, with a permit or - without. They come in step order, the step that sets the input side first when - it has them: `Moved from the sender`, its amount, worded as the `Amount` line - words one, or `Unknown (could not be decoded)` when the decoder does not know - it; `Token moved`, giving the token's address as the `Token In` line does; and + transaction and there is no `PERMIT2_PERMIT_BATCH`, its spender is the router, + the router may not let it fail, no step before it takes its token or a token + the decoder does not know, and the decoder reads every step after it in full. + Permit2 then lets the router take no more of the token than that amount, so + the permit covers every take of it. A later permit or batch may raise what + Permit2 lets the router take, and so may a step the decoder does not read in + full: a command the decoder does not read (listed at the end of this section), + a step whose input it cannot read, in whole or in part, and a step it fails + on. Every other `PERMIT2_PERMIT`, before the step that sets the input side or + after it, leaves `Token In` and the `Amount` line as they read with no permit: + a permit of 5,000 DAI to another address and then a V3 exact-in of 600 USDT + read `Swap USDT → USDC` with `Amount 600.0000 USDT`, and so do that exact-in + and then a permit of 5,000 DAI to the router. When a permit's amount stands + and the step that sets the input side spends a token other than the permit's, + or one the decoder does not know, that step's amount gets lines of its own, + unless it states no amount and the user pays it none of its input token. Each + take the total does not count gives three lines after the `Amount` line (after + `Token In` when there is none): one of a token other than `Token In`; one of + `Token In` after an amount that is not a quantity (a V4 open delta, + `ALREADY_PAID`, which keep their wording on the `Amount` line, or no stated + amount, as of a V4 exact-out step); and one whose amount the decoder does not + know. When the step that sets the input side states no amount, the user pays + it some of its input token, and there is no `Amount` line, that step gets them + too whenever another take's lines give a figure, or a `PERMIT2_TRANSFER_FROM` + of its token gives one, on the lines below or, into the router, on its + `Paid to` line, so that figure cannot read as all that leaves. When it states + no amount and the user pays it none of its input token, as when the router + pays it, that step takes none of that token from the user's wallet and never + gets them, with a permit or without. They come in step order, the step that + sets the input side first when it has them: `Moved from the sender`, its + amount, worded as the `Amount` line words one, or + `Unknown (could not be decoded)` when the decoder does not know it; + `Token moved`, giving the token's address as the `Token In` line does; and `Moved to`, which reads `The swap step that spends it`. A token the decoder does not know gives the `Moved from the sender` line alone. A step may also take from the user's wallet what the decoder did not read: a V4 step whose @@ -3284,17 +3286,25 @@ Currently supported: giving the token's address as the `Token In` line does; and `Allowance spender`, the address it lets take the token. Only a permit whose amount stands gives none: the `Amount` line gives its amount, `Token In` its - token, and its spender is the router. These lines change neither the `Paid to` - lines nor the minimum's label. A step goes unread when its recipient does not - decode as an address, when it is a V3 swap step whose path is shorter than one - token address or has bytes after its last hop, or when it is a command the - decoder does not read: every command other than `PERMIT2_PERMIT`, - `PERMIT2_TRANSFER_FROM`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, - `V3_SWAP_EXACT_IN`, `V3_SWAP_EXACT_OUT`, `V4_SWAP`, `SWEEP`, `TRANSFER`, - `PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`, `ACROSS_V4_DEPOSIT_V3`, - `WRAP_ETH`, `UNWRAP_WETH` and `BALANCE_CHECK_ERC20` - (`PERMIT2_TRANSFER_FROM_BATCH`, `EXECUTE_SUB_PLAN` and the NFT commands among - them), or a type its release names no command for. + token, and its spender is the router. Each allowance a `PERMIT2_PERMIT_BATCH` + grants, one for each token it names, all for its one spender, gives the same + three lines, in step order with those of each `PERMIT2_PERMIT`. A + `PERMIT2_PERMIT` or `PERMIT2_PERMIT_BATCH` whose input the decoder cannot + read, such as one whose token or spender word has a nonzero top byte, may let + any spender take any amount of any token: it gives one `Permit2 allowance` + line alone, reading `Unknown (could not be decoded)`, and leaves no `Amount` + line, whose figure might be too low. These lines change neither the `Paid to` + lines nor the minimum's label: a permit or batch pays nobody. A step goes + unread when its recipient does not decode as an address, when it is a V3 swap + step whose path is shorter than one token address or has bytes after its last + hop, or when it is a command the decoder does not read: every command other + than `PERMIT2_PERMIT`, `PERMIT2_PERMIT_BATCH`, `PERMIT2_TRANSFER_FROM`, + `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, `V3_SWAP_EXACT_IN`, + `V3_SWAP_EXACT_OUT`, `V4_SWAP`, `SWEEP`, `TRANSFER`, `PAY_PORTION`, + `PAY_PORTION_FULL_PRECISION`, `ACROSS_V4_DEPOSIT_V3`, `WRAP_ETH`, + `UNWRAP_WETH` and `BALANCE_CHECK_ERC20` (`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 246fb98..315e4df 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -336,7 +336,8 @@ const UNNAMED_CURRENCY = "Unknown (not named in the calldata)"; // currencies, and those commands may name one, so saying the calldata did not // name it would be false. The Paid to line uses it too, when a step may pay an // address this decoder did not read, and so do the lines for what a step -// takes from the sender, when this decoder does not know it. +// takes from the sender, when this decoder does not know it, and for what a +// permit it could not read allows. const UNDECODED_CURRENCY = "Unknown (could not be decoded)"; // The Paid to line for each token paid to the router, the swap's output or @@ -528,6 +529,32 @@ function decodePermit2(input) { } } +// Decode PERMIT2_PERMIT_BATCH (command 0x03) input bytes into the allowances +// it grants, one for each token it names, all for its one spender, in the +// shape decodePermit2() returns. Each release's +// `contracts/base/Dispatcher.sol`, at the commit linked for it above, hands +// Permit2 the batch in this shape. +// ABI: (((address token, uint160 amount, uint48 expiration, uint48 nonce)[], +// address spender, uint256 sigDeadline), bytes signature) +function decodePermit2Batch(input) { + try { + const d = coder.decode( + [ + "tuple(tuple(address,uint160,uint48,uint48)[],address,uint256)", + "bytes", + ], + input, + ); + return d[0][0].map((details) => ({ + token: details[0], + amount: details[1], + spender: d[0][1], + })); + } catch { + return null; + } +} + // Decode BALANCE_CHECK_ERC20 (command 0x0e) input bytes. The router passes the // check when `owner` holds at least `minBalance` of `token`, and reads `owner` // as written, without map(), on every release that has the command: @@ -1214,10 +1241,12 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // The last PERMIT2_PERMIT this decoder read before a step set the // input side, or null (see permitStands after the loop). let permit = null; - // Every PERMIT2_PERMIT this decoder read, before a step set the input - // side or after, in step order, as { token, amount, spender, step }. - // Each whose amount is not the Amount figure gets lines of its own - // (see after the PERMIT2_TRANSFER_FROM lines). + // Every allowance a PERMIT2_PERMIT or PERMIT2_PERMIT_BATCH grants, + // before a step set the input side or after, in step order, as + // { token, amount, spender, step }. A permit or batch whose input + // this decoder cannot read is one with a null token, amount and + // spender. Each but a permit whose amount is the Amount figure gets + // lines of its own (see after the PERMIT2_TRANSFER_FROM lines). const permits = []; // The index of each step this decoder did not read in full: a // command it does not read, a step whose input it cannot read, in @@ -1322,6 +1351,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) { const spentUnread = () => { userPaid.push({ token: null, amount: null }); }; + // A PERMIT2_PERMIT or PERMIT2_PERMIT_BATCH at `step` whose input this + // decoder cannot read may let any spender take any amount of any + // token: one line says so, and there is no Amount line, whose figure + // may then be too low. + const permitUnread = (step) => { + permits.push({ token: null, amount: null, spender: null, step }); + stepsUnread.push(step); + }; // Who the steps pay, in whichever step and of whatever token. // MSG_SENDER is the sender. The router itself, as ADDRESS_THIS or by @@ -1477,7 +1514,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { permits.push({ ...p, step: i }); keepsRouterWeth = true; } else { - stepsUnread.push(i); + permitUnread(i); } // Only a permit whose amount stands as the Amount figure // names Token In (see permitStands). Every other permit, @@ -1502,6 +1539,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) { beforeEveryTake: !tookBefore, }; } + } else if (command === "PERMIT2_PERMIT_BATCH") { + // Each allowance it grants gets the lines of a permit + // whose amount does not stand. It pays nobody. + const batch = decodePermit2Batch(inputs[i]); + if (batch) { + permits.push(...batch.map((p) => ({ ...p, step: i }))); + } else { + permitUnread(i); + } } else if (command === "PERMIT2_TRANSFER_FROM") { const p = decodePermit2TransferFrom(inputs[i]); if (!p) { @@ -1910,8 +1956,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } } else { // Any other step may pay an address this decoder does not - // read: PERMIT2_PERMIT_BATCH and the NFT commands among - // them, and a type the release names no command for. + // read: the NFT commands among them, and a type the + // release names no command for. payeeUnread = true; stepsUnread.push(i); } @@ -1961,17 +2007,19 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // Amount figure in place of the total, which it does only in the // plain shape of Uniswap's own routes: it comes before the step that // set the input side, it is the only PERMIT2_PERMIT in the - // transaction, its spender is the router, the router may not let it - // fail, no step before it took its token or one not known, and this - // decoder read every step after it in full (see stepsUnread). Permit2 - // then lets the router take no more of the token than that amount, so - // the permit covers every take of it. A later step this decoder did - // not read may raise what Permit2 lets the router take, as a - // PERMIT2_PERMIT_BATCH can. Every other PERMIT2_PERMIT leaves Token In - // and the Amount line as they read with no permit, and gets lines of - // its own below. A step that may take from the sender what this decoder - // did not read leaves no figure, permit or not (see spentUnread()), - // and an EXECUTE_SUB_PLAN step leaves both sides unknown (below). + // transaction and there is no PERMIT2_PERMIT_BATCH, its spender is + // the router, the router may not let it fail, no step before it took + // its token or one not known, and this decoder read every step after + // it in full (see stepsUnread). Permit2 then lets the router take no + // more of the token than that amount, so the permit covers every take + // of it. A later permit or batch, or a later step this decoder did + // not read, may raise what Permit2 lets the router take. Every other + // PERMIT2_PERMIT leaves Token In and the Amount line as they read with + // no permit, and gets lines of its own below. A step that may take + // from the sender what this decoder did not read leaves no figure, + // permit or not (see spentUnread()), and so does a permit or batch + // whose input it cannot read (see permitUnread()). An + // EXECUTE_SUB_PLAN step leaves both sides unknown (below). const stepCommands = Array.from( commandsBytes, (b) => commands[b & commandTypeMask], @@ -1979,6 +2027,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { const permitStands = permit !== null && stepCommands.filter((c) => c === "PERMIT2_PERMIT").length === 1 && + !stepCommands.includes("PERMIT2_PERMIT_BATCH") && sameAddress(permit.spender, toAddress) && !permit.allowedToFail && permit.beforeEveryTake && @@ -2147,11 +2196,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) { }; // A null token in `spentFromSender`, a step this decoder did not read - // or a token it does not know, leaves no Amount figure, which may - // then be too low. + // or a token it does not know, and one in `permits`, a permit or + // batch it could not read, leave no Amount figure, which may then be + // too low. if ( present(inputAmount) && - spentFromSender.every((s) => present(s.token)) + spentFromSender.every((s) => present(s.token)) && + permits.every((p) => present(p.token)) ) { const amount = spentText(inputAmount, inInfo); details.push({ @@ -2201,16 +2252,24 @@ function decode(data, toAddress, chainId, sources, fromAddress) { ); } - // What each PERMIT2_PERMIT lets its spender take: its amount, its - // token and the spender, an address on a line of its own. Permit2's - // maximum is unbounded. Only a permit whose amount stands as the - // Amount figure (see permitStands) gets none: that line already gives - // its amount and Token In its token, and its spender is the router. - // Every other permit, one after the step that set the input side - // included, gets these lines, so nothing it lets anyone take goes - // unstated. + // What each allowance of a PERMIT2_PERMIT or PERMIT2_PERMIT_BATCH lets + // its spender take: its amount, its token and the spender, an address + // on a line of its own. Permit2's maximum is unbounded. Only a permit + // whose amount stands as the Amount figure (see permitStands) gets + // none: that line already gives its amount and Token In its token, + // and its spender is the router. Every other allowance, one after the + // step that set the input side included, gets these lines, so nothing + // it lets anyone take goes unstated. A permit or batch this decoder + // could not read gets one line saying so. for (const p of permits) { if (permitStands && p.step === permit.step) continue; + if (!present(p.token)) { + details.push({ + label: "Permit2 allowance", + value: UNDECODED_CURRENCY, + }); + continue; + } details.push({ label: "Permit2 allowance", value: diff --git a/tests/uniswap.test.js b/tests/uniswap.test.js index 1dc64fe..9f0a322 100644 --- a/tests/uniswap.test.js +++ b/tests/uniswap.test.js @@ -59,6 +59,25 @@ function encodePermit2(token, amount, spender) { ); } +// Helper: encode a PERMIT2_PERMIT_BATCH input (command 0x03), one allowance +// for each [token, amount], all for `spender` +function encodePermit2Batch(allowances, spender) { + return coder.encode( + [ + "tuple(tuple(address,uint160,uint48,uint48)[],address,uint256)", + "bytes", + ], + [ + [ + allowances.map(([token, amount]) => [token, amount, 0, 0]), + spender, + 9999999999, + ], + "0x1234", + ], + ); +} + // Helper: encode a BALANCE_CHECK_ERC20 input (command 0x0e) function encodeBalanceCheck(owner, token, minBalance) { return coder.encode( @@ -2100,28 +2119,17 @@ describe("uniswap decoder", () => { ROUTER_ADDR, ); - // A PERMIT2_PERMIT_BATCH, which this decoder does not read, may raise - // what the router may take of the permit's token again. + // A PERMIT2_PERMIT_BATCH may raise what the router may take of the + // permit's token again, and its allowance gets lines of its own. test("a split after a PERMIT2_PERMIT and a PERMIT2_PERMIT_BATCH shows the total the steps take", () => { - const permitBatch = coder.encode( - [ - "tuple(tuple(address,uint160,uint48,uint48)[],address,uint256)", - "bytes", - ], - [ - [ - [[USDT_ADDR, 10000000000n, 0, 0]], // 10,000 USDT - ROUTER_ADDR, - 9999999999, - ], - "0x1234", - ], - ); const result = decodeSteps( [0x0a, 0x03, 0x00, 0x08], [ encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR), - permitBatch, + encodePermit2Batch( + [[USDT_ADDR, 10000000000n]], // 10,000 USDT + ROUTER_ADDR, + ), V3_600, encodeV2SwapExactIn(USER_ADDR, 9400000000n, 1n, [ USDT_ADDR, @@ -2132,6 +2140,12 @@ describe("uniswap decoder", () => { expect(linesBetweenTokens(result)).toEqual([ ["Amount", "10000.0000 USDT"], ...USDT_600_LINES, + ...permitLines( + "10000.0000 USDT", + USDT_ADDR, + "USDT", + ROUTER_ADDR, + ), ]); }); @@ -2418,6 +2432,121 @@ describe("uniswap decoder", () => { }); }); + // https://git.eeqj.de/sneak/AutistMask/issues/652 — a + // PERMIT2_PERMIT_BATCH, and a PERMIT2_PERMIT this decoder cannot read, + // showed nothing of what they allow. Each allowance a batch grants now + // gets the lines of a permit whose amount does not stand, and a permit + // or batch that cannot be read gets one line saying so and leaves no + // Amount figure. + describe("a PERMIT2_PERMIT_BATCH, or a permit that cannot be read", () => { + const USDT_5000 = 5000000000n; + const DAI_5000 = 5000000000000000000000n; + const MAX_UINT160 = 2n ** 160n - 1n; + + // A batch names one spender for all its allowances. + test.each([ + { kind: "the router", spender: ROUTER_ADDR }, + { kind: "another spender", spender: OTHER_ADDR }, + ])( + "a PERMIT2_PERMIT_BATCH of 5,000 USDT and unlimited DAI to $kind, then a V3 exact-in of 600 USDT, shows each allowance", + ({ spender }) => { + const result = decodeSteps( + [0x03, 0x00], + [ + encodePermit2Batch( + [ + [USDT_ADDR, USDT_5000], + [DAI_ADDR, MAX_UINT160], + ], + spender, + ), + V3_600, + ], + ); + expect(result.name).toBe("Swap USDT → USDC"); + expect(linesBetweenTokens(result)).toEqual([ + ["Amount", "600.0000 USDT"], + ...permitLines( + "5000.0000 USDT", + USDT_ADDR, + "USDT", + spender, + ), + ...permitLines("Unlimited", DAI_ADDR, "DAI", spender), + ]); + // A batch pays nobody, so the swap's minimum is what the + // sender receives. + expect(detail(result, "Paid to")).toBeUndefined(); + expect(detail(result, "Min. received").value).toBe( + "590.0000 USDC", + ); + }, + ); + + // A batch keeps a PERMIT2_PERMIT's amount from standing as the + // Amount figure, before the permit as after it. + test("a PERMIT2_PERMIT_BATCH of 5,000 DAI, then a PERMIT2_PERMIT of 5,000 USDT to the router and a V3 exact-in of 600 USDT, shows the swap's 600 USDT and both permits' lines", () => { + const result = decodeSteps( + [0x03, 0x0a, 0x00], + [ + encodePermit2Batch([[DAI_ADDR, DAI_5000]], ROUTER_ADDR), + encodePermit2(USDT_ADDR, USDT_5000, ROUTER_ADDR), + V3_600, + ], + ); + expect(linesBetweenTokens(result)).toEqual([ + ["Amount", "600.0000 USDT"], + ...permitLines( + "5000.0000 DAI", + DAI_ADDR, + "DAI", + ROUTER_ADDR, + ), + ...permitLines( + "5000.0000 USDT", + USDT_ADDR, + "USDT", + ROUTER_ADDR, + ), + ]); + }); + + // The permit's token word, and the batch's spender word, have a + // nonzero top byte. + test.each([ + { + kind: "a PERMIT2_PERMIT", + command: 0x0a, + input: dirtyWord( + encodePermit2(USDT_ADDR, USDT_5000, ROUTER_ADDR), + 0, + ), + }, + { + kind: "a PERMIT2_PERMIT_BATCH", + command: 0x03, + input: dirtyWord( + encodePermit2Batch( + [[USDT_ADDR, USDT_5000]], + ROUTER_ADDR, + ), + 3, + ), + }, + ])( + "$kind that cannot be read, then a V3 exact-in of 600 USDT, says its allowances could not be decoded and shows no Amount figure", + ({ command, input }) => { + const result = decodeSteps( + [command, 0x00], + [input, V3_600], + ); + expect(linesBetweenTokens(result)).toEqual([ + ["Permit2 allowance", UNDECODED], + ]); + }, + ); + }); + // A V3_SWAP_EXACT_OUT whose path is shorter than one token cannot be // read. It may pay any address and take from the sender, so it // leaves no Amount figure.