diff --git a/README.md b/README.md index 85cb8d7..0a529b8 100644 --- a/README.md +++ b/README.md @@ -3491,52 +3491,54 @@ Currently supported: 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 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, a - `PERMIT2_TRANSFER_FROM` of it included, 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 before the permit takes under whatever allowance the router - already had, so it may take more than the permit's amount, which would then - read lower than what leaves the user's wallet: a `PERMIT2_TRANSFER_FROM` of - 2,000 USDT into the router, then a permit of 600 USDT to the router and a V3 - exact-in of 2,000 USDT paid from the router, read `Amount 2000.0000 USDT`. 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, unless it lets a spender other than - 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 + its token is not the zero address, the router may not let it fail, no step + before it takes its token, a `PERMIT2_TRANSFER_FROM` of it included, 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. Permit2 has no allowance of ETH, so a + permit on the zero address covers no take of ETH. A step before the permit + takes under whatever allowance the router already had, so it may take more + than the permit's amount, which would then read lower than what leaves the + user's wallet: a `PERMIT2_TRANSFER_FROM` of 2,000 USDT into the router, then a + permit of 600 USDT to the router and a V3 exact-in of 2,000 USDT paid from the + router, read `Amount 2000.0000 USDT`. 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, + unless it lets a spender other than 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 `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 @@ -3569,30 +3571,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..9752395 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -2023,14 +2023,16 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // 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 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 (a PERMIT2_TRANSFER_FROM of its token - // included), 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 step - // before it takes under whatever allowance the router already had, so - // 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. + // the router, its token is not the zero address, the router may not + // let it fail, no step before it took its token or one not known (a + // PERMIT2_TRANSFER_FROM of its token included), 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. Permit2 has no allowance of ETH, so a + // permit on the zero address covers no take of ETH. A step before it + // takes under whatever allowance the router already had, so 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 @@ -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); @@ -2315,15 +2318,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..aeb1141 100644 --- a/tests/uniswap.test.js +++ b/tests/uniswap.test.js @@ -3310,6 +3310,98 @@ 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. + 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 the router. + const zeroAddressLines = (amount) => [ + [ + "Permit2 allowance", + amount + " base units (decimals unknown)", + ], + ["Allowance token", ZERO_ADDR], + ["Allowance spender", getAddress(ROUTER_ADDR)], + ]; + + 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"), + ]); + 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"), + ]); + }); + + 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), + 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, true), + encodeV4TakeAll(USDC_ADDR, 1n), + ], + ), + ], + ); + expect(detail(result, "Token In").value).toBe("ETH (native)"); + expect(linesBetweenTokens(result)).toEqual([ + ["Amount", "5.0000 ETH"], + ...zeroAddressLines("1"), + ]); + }); + }); }); // https://git.eeqj.de/sneak/AutistMask/issues/359 — the output half, in