From 36b200247a1e98160e231902e00e48fd76993f7d Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 27 Oct 2026 05:12:38 +0100 Subject: [PATCH] fix: a permit on the zero address never reads as ETH (closes #660) Permit2 has no allowance of ETH, but the swap decoder read the zero address in a PERMIT2_PERMIT or a batch entry as ETH. Its Allowance token line now names the zero address, as for a token nothing knows. Its amount never stands as the Amount figure, and it never counts as an allowance of Token In, so a route that pays ETH after such a permit shows that ETH. Tests cover the single permit, the batch entry and the 1-wei route. Model: opus-5-5 --- README.md | 108 ++++++++++++++++++----------------- src/shared/uniswap.js | 68 +++++++++++++++------- tests/uniswap.test.js | 129 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 232 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index a068572..c0b1e62 100644 --- a/README.md +++ b/README.md @@ -3506,34 +3506,36 @@ Currently supported: the router take `Token In` (see below): 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 there is no `Amount` line, each - take the total counts gets them too, so that every amount the decoder reads is - shown: the amount the step that sets the input side states, when it is a - quantity and the user pays that step (a V2 or V3 step whose `payerIsUser` is - true, a V4 step the user pays some of its input token, or a `WRAP_ETH`, whose - ETH comes from the transaction's value), and each later take of `Token In` - added to it. A step that sets the input side and is paid from the router takes - nothing from the user's wallet and gets none. A `PERMIT2_PERMIT` the decoder - cannot read, then V3 exact-ins of 600 USDT and 400 DAI paid by the user, give - lines for both amounts. 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, - except that the step that sets the input side comes first when it has them, - followed, when there is no `Amount` line, by the other takes the total counts: - `Moved from the sender`, its amount, worded as the `Amount` line words one, or + to the router. A permit on the zero address never stands as the `Amount` + figure, because Permit2 has no allowance of ETH. 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 there is no `Amount` line, each take the total counts gets them + too, so that every amount the decoder reads is shown: the amount the step that + sets the input side states, when it is a quantity and the user pays that step + (a V2 or V3 step whose `payerIsUser` is true, a V4 step the user pays some of + its input token, or a `WRAP_ETH`, whose ETH comes from the transaction's + value), and each later take of `Token In` added to it. A step that sets the + input side and is paid from the router takes nothing from the user's wallet + and gets none. A `PERMIT2_PERMIT` the decoder cannot read, then V3 exact-ins + of 600 USDT and 400 DAI paid by the user, give lines for both amounts. 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, except that the step that sets + the input side comes first when it has them, followed, when there is no + `Amount` line, by the other takes the total counts: `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 @@ -3566,30 +3568,32 @@ Currently supported: amount stands gives none: the `Amount` line gives its amount, `Token In` its 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)`. These lines change no - `Paid to` line: a permit or batch pays nobody. But code that a step runs, such - as a V4 pool's hook or a token's own code, can call a spender other than the - router, which can then take up to its allowance from the user's wallet before - the transaction ends. So an allowance to any spender but the router, or a - permit or batch the decoder cannot read, makes the minimum read - `Min. paid out`, and when its token is `Token In`, or the decoder does not - know its token or `Token In`, it leaves no `Amount` line, whose figure might - be too low: a permit of 5,000 USDT to another address and then a V3 exact-in - of 600 USDT read with no `Amount` line, and the 600 USDT gets lines of its - own. 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. + three lines, in step order with those of each `PERMIT2_PERMIT`. For an + allowance of the zero address, `Permit2 allowance` gives the amount in base + units and `Allowance token` gives the zero address. 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)`. These lines change no `Paid to` line: a + permit or batch pays nobody. But code that a step runs, such as a V4 pool's + hook or a token's own code, can call a spender other than the router, which + can then take up to its allowance from the user's wallet before the + transaction ends. So an allowance to any spender but the router, or a permit + or batch the decoder cannot read, makes the minimum read `Min. paid out`, and + when its token is `Token In`, or the decoder does not know its token or + `Token In`, it leaves no `Amount` line, whose figure might be too low: a + permit of 5,000 USDT to another address and then a V3 exact-in of 600 USDT + read with no `Amount` line, and the 600 USDT gets lines of its own. 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 2a00fff..5f02dda 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -2032,12 +2032,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // it may take more. 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 do a permit - // or batch whose input it cannot read (see permitUnread()) and an - // allowance of Token In to another spender (see toAnotherSpender). An - // EXECUTE_SUB_PLAN step leaves both sides unknown (below). + // they read with no permit, and gets lines of its own below. A permit + // on the zero address never stands as the Amount figure, because + // Permit2 has no allowance of ETH. A step that may take from the + // sender what this decoder did not read leaves no figure, permit or + // not (see spentUnread()), and so do a permit or batch whose input it + // cannot read (see permitUnread()) and an allowance of Token In to + // another spender (see toAnotherSpender). An EXECUTE_SUB_PLAN step + // leaves both sides unknown (below). const stepCommands = Array.from( commandsBytes, (b) => commands[b & commandTypeMask], @@ -2047,6 +2049,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { stepCommands.filter((c) => c === "PERMIT2_PERMIT").length === 1 && !stepCommands.includes("PERMIT2_PERMIT_BATCH") && sameAddress(permit.spender, toAddress) && + permit.token !== "0x0000000000000000000000000000000000000000" && !permit.allowedToFail && permit.beforeEveryTake && !stepsUnread.some((step) => step > permit.step); @@ -2234,17 +2237,19 @@ 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, and an allowance to another spender // that may be of Token In, leave no Amount figure, which may then be - // too low. With no Amount line, each take the total adds up gets - // lines of its own, first, so that every amount this decoder read is - // shown. + // too low. An allowance of the zero address is never one of Token In, + // because Permit2 has no allowance of ETH. With no Amount line, each + // take the total adds up gets lines of its own, first, so that every + // amount this decoder read is shown. if ( present(inputAmount) && spentFromSender.every((s) => present(s.token)) && toAnotherSpender.every( (p) => - present(p.token) && - present(inputToken) && - !sameAddress(p.token, inputToken), + p.token === "0x0000000000000000000000000000000000000000" || + (present(p.token) && + present(inputToken) && + !sameAddress(p.token, inputToken)), ) ) { const amount = spentText(inputAmount, inInfo); @@ -2315,15 +2320,36 @@ function decode(data, toAddress, chainId, sources, fromAddress) { }); continue; } - details.push({ - label: "Permit2 allowance", - value: - p.amount >= MAX_UINT160 - ? "Unlimited" - : amountText(p.amount, tokenInfo(p.token, sources)) - .display, - }); - details.push(tokenMovedLine("Allowance token", p.token, sources)); + // Permit2 has no allowance of ETH. An allowance of the zero + // address reads as one of a token nothing knows: its amount in + // base units, and the zero address as its token. + if (p.token === "0x0000000000000000000000000000000000000000") { + details.push({ + label: "Permit2 allowance", + value: + p.amount >= MAX_UINT160 + ? "Unlimited" + : unknownDecimalsAmount(p.amount), + }); + details.push({ + label: "Allowance token", + value: p.token, + address: p.token, + isToken: true, + }); + } else { + details.push({ + label: "Permit2 allowance", + value: + p.amount >= MAX_UINT160 + ? "Unlimited" + : amountText(p.amount, tokenInfo(p.token, sources)) + .display, + }); + details.push( + tokenMovedLine("Allowance token", p.token, sources), + ); + } details.push({ label: "Allowance spender", value: p.spender, diff --git a/tests/uniswap.test.js b/tests/uniswap.test.js index c0e3b64..46d7ff9 100644 --- a/tests/uniswap.test.js +++ b/tests/uniswap.test.js @@ -3310,6 +3310,135 @@ describe("uniswap decoder", () => { expect(detail(result, "Amount").value).toBe("Up to 1000.0000 USDT"); }); + + // https://git.eeqj.de/sneak/AutistMask/issues/660 — Permit2 has no + // allowance of ETH, so a permit on the zero address reads as one on a + // token nothing knows. On next at 53551c6 such a permit's Allowance + // token line read ETH. A permit of 1 wei of the zero address before a + // V4 step of 5 ETH made the Amount line read 0.000000000000000001 ETH. + // With that permit to another spender and a SETTLE the router pays, no + // line gave the 5 ETH. + describe("a permit on the zero address", () => { + const FIVE_ETH = 5000000000000000000n; + // The lines of a permit of `amount` base units of the zero + // address for `spender`. + const zeroAddressLines = (amount, spender) => [ + [ + "Permit2 allowance", + amount + " base units (decimals unknown)", + ], + ["Allowance token", ZERO_ADDR], + ["Allowance spender", getAddress(spender)], + ]; + // A V4 swap of 5 ETH for USDC. The user pays its SETTLE when + // `payerIsUser` is true; otherwise the router pays it from the + // transaction's value. + const v4FiveEth = (payerIsUser) => + encodeV4Swap( + new Uint8Array([ + V4_SWAP_EXACT_IN_SINGLE_ID, + V4_SETTLE_ID, + V4_TAKE_ALL_ID, + ]), + [ + encodeV4ExactInSingle( + ZERO_ADDR, + USDC_ADDR, + FIVE_ETH, + 1n, + ), + settle(ZERO_ADDR, FIVE_ETH, payerIsUser), + encodeV4TakeAll(USDC_ADDR, 1n), + ], + ); + + test("a PERMIT2_PERMIT on the zero address names the address", () => { + const result = decodeSteps( + [0x00, 0x0a], + [ + V3_600, + encodePermit2( + ZERO_ADDR, + 1000000000000000000n, + ROUTER_ADDR, + ), + ], + ); + expect(linesBetweenTokens(result)).toEqual([ + ["Amount", "600.0000 USDT"], + ...zeroAddressLines("1000000000000000000", ROUTER_ADDR), + ]); + expect(detail(result, "Allowance token").address).toBe( + ZERO_ADDR, + ); + }); + + test("a PERMIT2_PERMIT_BATCH entry on the zero address names the address", () => { + const result = decodeSteps( + [0x03, 0x00], + [ + encodePermit2Batch( + [ + [USDT_ADDR, USDT_600], + [ZERO_ADDR, 1000000000000000000n], + ], + ROUTER_ADDR, + ), + V3_600, + ], + ); + expect(linesBetweenTokens(result)).toEqual([ + ["Amount", "600.0000 USDT"], + ...USDT_600_LINES, + ...zeroAddressLines("1000000000000000000", ROUTER_ADDR), + ]); + }); + + test("a PERMIT2_PERMIT of 1 wei of the zero address, then a V4 step of 5 ETH the user settles, shows the 5 ETH", () => { + const result = decodeSteps( + [0x0a, 0x10], + [ + encodePermit2(ZERO_ADDR, 1n, ROUTER_ADDR), + v4FiveEth(true), + ], + ); + expect(detail(result, "Token In").value).toBe("ETH (native)"); + expect(linesBetweenTokens(result)).toEqual([ + ["Amount", "5.0000 ETH"], + ...zeroAddressLines("1", ROUTER_ADDR), + ]); + }); + + test("a PERMIT2_PERMIT of 1 wei of the zero address to another spender, then a V4 step of 5 ETH the router settles, shows the 5 ETH", () => { + const result = decodeSteps( + [0x0a, 0x10], + [ + encodePermit2(ZERO_ADDR, 1n, OTHER_ADDR), + v4FiveEth(false), + ], + ); + expect(detail(result, "Token In").value).toBe("ETH (native)"); + expect(linesBetweenTokens(result)).toEqual([ + ["Amount", "5.0000 ETH"], + ...zeroAddressLines("1", OTHER_ADDR), + ]); + }); + + test("a PERMIT2_PERMIT_BATCH entry of 1 wei of the zero address to another spender, then a V4 step of 5 ETH the router settles, shows the 5 ETH", () => { + const result = decodeSteps( + [0x03, 0x10], + [ + encodePermit2Batch([[ZERO_ADDR, 1n]], OTHER_ADDR), + v4FiveEth(false), + ], + ); + expect(detail(result, "Token In").value).toBe("ETH (native)"); + expect(linesBetweenTokens(result)).toEqual([ + ["Amount", "5.0000 ETH"], + ...zeroAddressLines("1", OTHER_ADDR), + ]); + }); + }); }); // https://git.eeqj.de/sneak/AutistMask/issues/359 — the output half, in