From f30b77e1d43cb482deedbc2cdc6f6e22c6b49c7d Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 27 Oct 2026 02:56:35 +0000 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 a permit's zero-address token as ETH. Its Allowance token line read ETH. A permit of 1 wei of the zero address before a V4 step of 5 ETH stood as the Amount figure, which then read 0.000000000000000001 ETH. With that permit to another spender, there was no Amount line. Such a permit now never stands as the Amount figure. To another spender, it no longer removes the Amount line either. Its lines give the amount in base units and name the zero address, as for a token nothing knows. 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 -- 2.54.0