diff --git a/README.md b/README.md index 527778c..9c13e18 100644 --- a/README.md +++ b/README.md @@ -1129,13 +1129,15 @@ scale is not an approximation but a different number — 1,000 units of a 6-decimal token formatted at 18 decimals reads `0.000000001` — on the screen whose only job is to state what is being authorized. Both amount paths of that screen take this rule: the ERC-20 `transfer`/`approve` line -(`src/popup/views/approval.js`) and the swap's `Amount` and `Min. received` -lines (`src/shared/uniswap.js`). The token permission warning on the signature -screen takes the same rule for its amounts. An unbounded allowance or permit -needs no scale to describe and is still shown as `Unlimited`. A source's answer -counts only if it is a whole number from 0 to 80: `decimals()` returns a -`uint8`, but `formatUnits()` cannot format more than 80 decimal places, so a -token that reports 81 to 255 is shown as one whose scale nothing knows. +(`src/popup/views/approval.js`) and the swap's `Amount`, `Min. received`, +`Moved from the sender` and `Permit2 allowance` lines and the `Paid to` line of +a move into the router (`src/shared/uniswap.js`). The token permission warning +on the signature screen takes the same rule for its amounts. An unbounded +allowance or permit needs no scale to describe and is still shown as +`Unlimited`. A source's answer counts only if it is a whole number from 0 to 80: +`decimals()` returns a `uint8`, but `formatUnits()` cannot format more than 80 +decimal places, so a token that reports 81 to 255 is shown as one whose scale +nothing knows. The rule holds only if nothing invents a scale UPSTREAM of it. Those three sources are read as authoritative, so a value written into one of them cannot be @@ -2425,10 +2427,11 @@ view would leave a wallet one click from deletion. transaction" - 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, 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) + 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) - 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 @@ -3217,40 +3220,42 @@ Currently supported: added to the total while both are quantities: a V3 exact-in of 600 USDT and a V2 exact-in of 400 USDT, both paid by the user, read `1000.0000 USDT`. When the decoder does not know a take's amount, the total is not known and there is - no `Amount` line. `Token In` is the token of the last `PERMIT2_PERMIT` the - decoder reads, when there is one. Its amount, `Unlimited` included, stands as - the `Amount` figure in place of the total only in the plain shape of Uniswap's - own routes: it is the only `PERMIT2_PERMIT`, 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. - In any other case the line gives the total, as with no permit. When the step - that sets the input side spends a token other than the permit's, or one the - decoder does not know, its amount gets lines of its own, unless it states no - amount and the user pays it none of its input token, and the total has no - figure to start from: unless the permit's amount stands there is no `Amount` - line, and each take of the permit's token gets lines of its own. 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 + no `Amount` line. A `PERMIT2_PERMIT` names `Token In` only when its amount, + `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 `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 @@ -3271,11 +3276,19 @@ Currently supported: on every router but `UniversalRouterV1`, and otherwise gives the address. A move into the router needs none of them, because its `Paid to` line and `Token moved into the router` line already give the amount, the token and - where it goes, and so its amount is shown once. 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`, + where it goes, and so its amount is shown once. Every `PERMIT2_PERMIT` whose + amount does not stand as the `Amount` figure, before the step that sets the + input side or after it, gives three lines, in step order, after those for each + `PERMIT2_TRANSFER_FROM`: `Permit2 allowance`, its amount, for example + `5000.0000 DAI`, or `Unlimited` for Permit2's maximum; `Allowance token`, + 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`, diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index 6b2683e..246fb98 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -494,8 +494,9 @@ function amountText(raw, info) { }; } -// The line giving a moved token under `label`, in the shape of the Token In -// line, so the screens show it as they show that one. ETH has no address. +// The line giving a moved token, or the token of a permit, under `label`, in +// the shape of the Token In line, so the screens show it as they show that +// one. ETH has no address. function tokenMovedLine(label, token, sources) { const info = tokenInfo(token, sources); if (!present(info.address)) { @@ -596,12 +597,14 @@ function decodeV2SwapExactOut(input) { // token(20) ...), or null when it is not one token followed by whole hops of // a fee and a token (40 hex digits, then 46 per hop). The router reads only // whole hops and ignores any bytes after the last one, so the last 20 bytes -// of such a path are not the token it reads there. +// of such a path are not the token it reads there. Both are checksummed, as +// the ABI decoder gives every other address, so a token reads the same on +// every line. function decodeV3Path(pathHex) { const hex = pathHex.startsWith("0x") ? pathHex.slice(2) : pathHex; if (hex.length < 40 || (hex.length - 40) % 46 !== 0) return null; - const first = "0x" + hex.slice(0, 40); - const last = "0x" + hex.slice(-40); + const first = getAddress("0x" + hex.slice(0, 40)); + const last = getAddress("0x" + hex.slice(-40)); return { first, last }; } @@ -1208,9 +1211,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // its input currency (see decodeV4Swap()). Only a V4 step can set the // input side with no amount, the one case in which this is read. let inputPaidByUser = false; - // The last PERMIT2_PERMIT this decoder read, or null (see - // permitStands after the loop). + // 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). + 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 // whole or in part, and one it fails on (see permitStands). @@ -1268,8 +1276,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // when no earlier hop set it. A half the establishing hop did not // state stays null and the line says so, rather than being filled in // from a different hop. A PERMIT2_PERMIT sets no side here: after the - // loop, the last one read names Token In, and the Amount line is - // formed there, in one place (see permitStands). + // loop, one whose amount stands as the Amount figure names Token In, + // and the Amount line is formed there, in one place (see + // permitStands). const setInput = (token, amount) => { inputToken = present(token) ? token : null; inputAmount = present(amount) ? amount : null; @@ -1465,24 +1474,33 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } else if (command === "PERMIT2_PERMIT") { const p = decodePermit2(inputs[i]); if (p) { + permits.push({ ...p, step: i }); + keepsRouterWeth = true; + } else { + stepsUnread.push(i); + } + // Only a permit whose amount stands as the Amount figure + // names Token In (see permitStands). Every other permit, + // before the step that sets the input side or after it, + // leaves Token In and Amount as they read with no permit + // and gets lines of its own. One after a step set the input + // side never stands, so only one before it is kept here. + if (p && !inputEstablished) { // Whether a step before it took its token, or may - // have: the step that set the input side or one in - // `userPaid`, when its token is the permit's or not - // known. + // have: one in `userPaid`, when its token is the + // permit's or not known. No step has set the input + // side yet. const mayBeItsToken = (token) => !present(token) || sameAddress(token, p.token); - const tookBefore = - (inputEstablished && mayBeItsToken(inputToken)) || - userPaid.some((t) => mayBeItsToken(t.token)); + const tookBefore = userPaid.some((t) => + mayBeItsToken(t.token), + ); permit = { ...p, step: i, allowedToFail, beforeEveryTake: !tookBefore, }; - keepsRouterWeth = true; - } else { - stepsUnread.push(i); } } else if (command === "PERMIT2_TRANSFER_FROM") { const p = decodePermit2TransferFrom(inputs[i]); @@ -1939,18 +1957,21 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // lines of its own. One whose amount is not known also leaves the // total unknown, with no figure. // - // The last PERMIT2_PERMIT read names Token In. Its amount stands as - // the Amount figure in place of the total only in the plain shape of - // Uniswap's own routes: it is the only PERMIT2_PERMIT, 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. 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). + // A PERMIT2_PERMIT names Token In only when its amount 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 + // 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). const stepCommands = Array.from( commandsBytes, (b) => commands[b & commandTypeMask], @@ -1977,17 +1998,17 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // The take of the step that set the input side, when the total // starts from it and it stated no amount (see below), or null. let firstTakeUnstated = null; - if (permit === null || sameAddress(inputToken, permit.token)) { + if (!permitStands || sameAddress(inputToken, permit.token)) { total = inputAmount; if (hasFirstTake && !present(inputAmount)) { firstTakeUnstated = { token: inputToken, amount: null }; } } else if (hasFirstTake) { // The step that set the input side spent a token other than the - // permit's, or one not known, so the total starts with no figure. + // permit's, or one not known, so its amount gets lines of its own. spentFromSender.push({ token: inputToken, amount: inputAmount }); } - if (permit !== null) inputToken = permit.token; + if (permitStands) inputToken = permit.token; for (const t of userPaid) { const ofTokenIn = sameAddress(t.token, inputToken); if (ofTokenIn && permitStands) continue; @@ -2020,10 +2041,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } // The commands of an EXECUTE_SUB_PLAN step may spend the user's tokens - // or pay the user, before or after any other step, and a permit among - // them would name Token In. What the other steps state is - // then not the whole swap, so both sides are set unknown, with no - // figure, wherever the step stands. + // or pay the user, before or after any other step. What the other + // steps state is then not the whole swap, so both sides are set + // unknown, with no figure, wherever the step stands. if (hasSubPlan) { inputToken = null; inputAmount = null; @@ -2181,6 +2201,32 @@ 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. + for (const p of permits) { + if (permitStands && p.step === permit.step) 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)); + details.push({ + label: "Allowance spender", + value: p.spender, + address: p.spender, + }); + } + // Keyed on the address, not the symbol: a token absent from the // bundled list has no symbol, and gating the line on one dropped it // entirely, leaving a Min. received figure with nothing saying what is diff --git a/tests/uniswap.test.js b/tests/uniswap.test.js index 9144d6a..1dc64fe 100644 --- a/tests/uniswap.test.js +++ b/tests/uniswap.test.js @@ -1,4 +1,4 @@ -const { AbiCoder, Interface, solidityPacked } = require("ethers"); +const { AbiCoder, Interface, getAddress, solidityPacked } = require("ethers"); const uniswap = require("../src/shared/uniswap"); // A Universal Router on Ethereum Mainnet, chain id 0x1: UniversalRouterV2, @@ -696,12 +696,8 @@ describe("uniswap decoder", () => { USER_ADDR, ); expect(result.name).toBe("Swap USDT → WETH"); - expect(detail(result, "Token In").address).toBe( - USDT_ADDR.toLowerCase(), - ); - expect(detail(result, "Token Out").address).toBe( - WETH_ADDR.toLowerCase(), - ); + expect(detail(result, "Token In").address).toBe(USDT_ADDR); + expect(detail(result, "Token Out").address).toBe(WETH_ADDR); const amount = detail(result, "Amount"); expect(amount.value).toBe("Up to 2000.0000 USDT"); @@ -735,9 +731,7 @@ describe("uniswap decoder", () => { expect(result.name).toBe("Swap ETH → USDC"); expect(detail(result, "Amount").value).toBe("Up to 0.5000 ETH"); expect(detail(result, "Moved from the sender")).toBeUndefined(); - expect(detail(result, "Token Out").address).toBe( - USDC_ADDR.toLowerCase(), - ); + expect(detail(result, "Token Out").address).toBe(USDC_ADDR); expect(detail(result, "Paid to")).toBeUndefined(); expect(detail(result, "Min. received").value).toBe("1500.0000 USDC"); }); @@ -1406,6 +1400,14 @@ describe("uniswap decoder", () => { return lines.slice(from + 1, to); } + // The lines of a PERMIT2_PERMIT whose amount does not stand as the + // Amount figure, of `amount` of `token` for `spender`. + const permitLines = (amount, token, symbol, spender) => [ + ["Permit2 allowance", amount], + ["Allowance token", symbol + " (" + token + ")"], + ["Allowance spender", getAddress(spender)], + ]; + // execute() of these command bytes and inputs, decoded as sent by // the user. function decodeSteps(commands, inputs) { @@ -1711,6 +1713,7 @@ describe("uniswap decoder", () => { kind: "a V4 step swapping 400 USDT with a SETTLE of 5,000 USDT by the user, the rest taken to another address,", commands: [0x10], inputs: [V4_SETTLE_5000], + permit: [], }, { kind: "a PERMIT2_PERMIT of 1 USDT that the router may let fail, then that V4 step,", @@ -1719,6 +1722,12 @@ describe("uniswap decoder", () => { encodePermit2(USDT_ADDR, 1000000n, ROUTER_ADDR), V4_SETTLE_5000, ], + permit: permitLines( + "1.0000 USDT", + USDT_ADDR, + "USDT", + ROUTER_ADDR, + ), }, // The unread SWEEP keeps the permit's amount from standing. { @@ -1735,11 +1744,18 @@ describe("uniswap decoder", () => { 1, ), ], + permit: permitLines( + "5000.0000 USDT", + USDT_ADDR, + "USDT", + ROUTER_ADDR, + ), }, - ])("$kind shows the 5,000 USDT", ({ commands, inputs }) => { + ])("$kind shows the 5,000 USDT", ({ commands, inputs, permit }) => { const result = decodeSteps(commands, inputs); expect(linesBetweenTokens(result)).toEqual([ ["Amount", "5000.0000 USDT"], + ...permit, ]); }); @@ -2023,19 +2039,24 @@ describe("uniswap decoder", () => { }); // Out of that shape the permit caps nothing the Amount line can rely - // on, so the line gives the total the steps take, as with no permit. + // on, so the line gives the total the steps take, as with no permit, + // and each permit gets lines of its own. const ONE_USDT_PERMIT = (spender) => encodePermit2(USDT_ADDR, 1000000n, spender); + const ONE_USDT_LINES = (spender) => + permitLines("1.0000 USDT", USDT_ADDR, "USDT", spender); test.each([ { kind: "to another spender", commands: [0x0a], permits: [ONE_USDT_PERMIT(OTHER_ADDR)], + lines: ONE_USDT_LINES(OTHER_ADDR), }, { kind: "that the router lets fail", commands: [0x8a], permits: [ONE_USDT_PERMIT(ROUTER_ADDR)], + lines: ONE_USDT_LINES(ROUTER_ADDR), }, { kind: "followed by a second one", @@ -2044,10 +2065,14 @@ describe("uniswap decoder", () => { ONE_USDT_PERMIT(ROUTER_ADDR), ONE_USDT_PERMIT(ROUTER_ADDR), ], + lines: [ + ...ONE_USDT_LINES(ROUTER_ADDR), + ...ONE_USDT_LINES(ROUTER_ADDR), + ], }, ])( "a split after a PERMIT2_PERMIT $kind shows the total the steps take", - ({ commands, permits }) => { + ({ commands, permits, lines }) => { const result = decodeSteps( [...commands, 0x00, 0x08], [ @@ -2061,10 +2086,20 @@ describe("uniswap decoder", () => { ); expect(linesBetweenTokens(result)).toEqual([ ["Amount", "1000.0000 USDT"], + ...lines, ]); }, ); + // The lines of a PERMIT2_PERMIT of 600 USDT to the router whose + // amount does not stand. + const USDT_600_LINES = permitLines( + "600.0000 USDT", + USDT_ADDR, + "USDT", + 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. test("a split after a PERMIT2_PERMIT and a PERMIT2_PERMIT_BATCH shows the total the steps take", () => { @@ -2096,6 +2131,7 @@ describe("uniswap decoder", () => { ); expect(linesBetweenTokens(result)).toEqual([ ["Amount", "10000.0000 USDT"], + ...USDT_600_LINES, ]); }); @@ -2141,6 +2177,7 @@ describe("uniswap decoder", () => { ); expect(linesBetweenTokens(result)).toEqual([ ["Amount", "10000.0000 USDT"], + ...USDT_600_LINES, ]); }, ); @@ -2168,8 +2205,9 @@ describe("uniswap decoder", () => { ]; // What a V3_SWAP_EXACT_OUT takes, at most its amountInMax, counts // as a V2_SWAP_EXACT_OUT's does, and the Amount line is a maximum. - // The permit of 10,000 USDT stands; the one of 600 USDT does not, - // because the step before it took its token. + // The permit of 10,000 USDT stands. The one of 600 USDT comes after + // the step that set the input side, so it does not stand and gets + // lines of its own. test.each([ { kind: "a V3 exact-in of 600 USDT, then a V3_SWAP_EXACT_OUT of at most 400 USDT", @@ -2201,17 +2239,185 @@ describe("uniswap decoder", () => { V3_600, ], amount: "Up to 10000.0000 USDT", + permit: USDT_600_LINES, }, ])( "$kind, all paid by the user, shows the total as a maximum", - ({ commands, inputs, amount }) => { + ({ commands, inputs, amount, permit = [] }) => { const result = decodeSteps(commands, inputs); expect(linesBetweenTokens(result)).toEqual([ ["Amount", amount], + ...permit, ]); }, ); + // https://git.eeqj.de/sneak/AutistMask/issues/370 — a PERMIT2_PERMIT + // after the step that set the input side named Token In in place of + // that step's token, and its amount could stand as the Amount figure. + // Only a permit whose amount stands as the Amount figure names Token + // In now. Every other, before the swap steps or after them, leaves + // Token In and Amount as they read with no permit and gets lines of + // its own. + describe("a PERMIT2_PERMIT whose amount does not stand as the Amount figure", () => { + const DAI_5000 = 5000000000000000000000n; + const USDT_5000 = 5000000000n; + const MAX_UINT160 = 2n ** 160n - 1n; + + test.each([ + { + kind: "a V3 exact-in of 600 USDT, then a PERMIT2_PERMIT of 5,000 DAI to the router,", + commands: [0x00, 0x0a], + inputs: [ + V3_600, + encodePermit2(DAI_ADDR, DAI_5000, ROUTER_ADDR), + ], + lines: permitLines( + "5000.0000 DAI", + DAI_ADDR, + "DAI", + ROUTER_ADDR, + ), + }, + { + kind: "a V3 exact-in of 600 USDT, then a PERMIT2_PERMIT of unlimited DAI to another spender,", + commands: [0x00, 0x0a], + inputs: [ + V3_600, + encodePermit2(DAI_ADDR, MAX_UINT160, OTHER_ADDR), + ], + lines: permitLines( + "Unlimited", + DAI_ADDR, + "DAI", + OTHER_ADDR, + ), + }, + { + kind: "a V3 exact-in of 600 USDT, then a PERMIT2_PERMIT of 600 USDT, the swap's own token, to the router,", + commands: [0x00, 0x0a], + inputs: [ + V3_600, + encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR), + ], + lines: USDT_600_LINES, + }, + { + kind: "a PERMIT2_PERMIT of 5,000 USDT to another spender, then a V3 exact-in of 600 USDT,", + commands: [0x0a, 0x00], + inputs: [ + encodePermit2(USDT_ADDR, USDT_5000, OTHER_ADDR), + V3_600, + ], + lines: permitLines( + "5000.0000 USDT", + USDT_ADDR, + "USDT", + OTHER_ADDR, + ), + }, + { + kind: "a PERMIT2_PERMIT of 5,000 USDT to the router that the router may let fail, then a V3 exact-in of 600 USDT,", + commands: [0x8a, 0x00], + inputs: [ + encodePermit2(USDT_ADDR, USDT_5000, ROUTER_ADDR), + V3_600, + ], + lines: permitLines( + "5000.0000 USDT", + USDT_ADDR, + "USDT", + ROUTER_ADDR, + ), + }, + { + kind: "a PERMIT2_PERMIT of 5,000 DAI to another spender, then a V3 exact-in of 600 USDT,", + commands: [0x0a, 0x00], + inputs: [ + encodePermit2(DAI_ADDR, DAI_5000, OTHER_ADDR), + V3_600, + ], + lines: permitLines( + "5000.0000 DAI", + DAI_ADDR, + "DAI", + OTHER_ADDR, + ), + }, + { + kind: "a PERMIT2_PERMIT of 5,000 DAI to the router, a V3 exact-in of 600 USDT, then a PERMIT2_PERMIT of 600 USDT to the router,", + commands: [0x0a, 0x00, 0x0a], + inputs: [ + encodePermit2(DAI_ADDR, DAI_5000, ROUTER_ADDR), + V3_600, + encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR), + ], + lines: [ + ...permitLines( + "5000.0000 DAI", + DAI_ADDR, + "DAI", + ROUTER_ADDR, + ), + ...USDT_600_LINES, + ], + }, + ])( + "$kind shows the swap's 600 USDT and each permit's own lines", + ({ commands, inputs, lines }) => { + const result = decodeSteps(commands, inputs); + expect(result.name).toBe("Swap USDT → USDC"); + // The swap step names Token In, its address checksummed, + // as on a permit's Allowance token line. + expect(detail(result, "Token In").value).toBe( + "USDT (" + USDT_ADDR + ")", + ); + expect(linesBetweenTokens(result)).toEqual([ + ["Amount", "600.0000 USDT"], + ...lines, + ]); + expect(detail(result, "Amount").rawValue).toBe("600.0000"); + }, + ); + + // With two permits neither amount stands, so the Amount line + // gives the total the steps take of USDT, the 400 DAI the last + // step takes gets lines of its own, and so does each permit. + test("a PERMIT2_PERMIT of USDT, a V3 exact-in of 600 USDT, a PERMIT2_PERMIT of DAI and a V2 exact-in of 400 DAI shows both tokens' amounts and each permit's own lines", () => { + const result = decodeSteps( + [0x0a, 0x00, 0x0a, 0x08], + [ + encodePermit2(USDT_ADDR, USDT_5000, ROUTER_ADDR), + V3_600, + encodePermit2(DAI_ADDR, DAI_400, ROUTER_ADDR), + encodeV2SwapExactIn(USER_ADDR, DAI_400, 390000000n, [ + DAI_ADDR, + USDC_ADDR, + ]), + ], + ); + expect(result.name).toBe("Swap USDT → USDC"); + expect(linesBetweenTokens(result)).toEqual([ + ["Amount", "600.0000 USDT"], + ["Moved from the sender", "400.0000 DAI"], + ["Token moved", "DAI (" + DAI_ADDR + ")"], + ["Moved to", "The swap step that spends it"], + ...permitLines( + "5000.0000 USDT", + USDT_ADDR, + "USDT", + ROUTER_ADDR, + ), + ...permitLines( + "400.0000 DAI", + DAI_ADDR, + "DAI", + ROUTER_ADDR, + ), + ]); + }); + }); + // 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. @@ -2257,7 +2463,7 @@ describe("uniswap decoder", () => { // A V3 exact-out of 1,500 USDC for at most 0.5 WETH spends WETH, not // SPK. After it, a PERMIT2_PERMIT of 1 WETH and a V3 exact-in of 0.5 // WETH: the permit's amount does not stand, because the step before - // it also takes WETH. + // it also takes WETH, so the permit gets lines of its own. test("a V3_SWAP_EXACT_OUT whose path has bytes after its last hop, then a PERMIT2_PERMIT and a V3 exact-in, shows no Amount figure", () => { const path = pathEndingIn([USDC_ADDR, WETH_ADDR], SPK_ADDR); const result = decodeSteps( @@ -2273,6 +2479,7 @@ describe("uniswap decoder", () => { ); expect(linesBetweenTokens(result)).toEqual([ ["Moved from the sender", UNDECODED], + ...permitLines("1.0000 WETH", WETH_ADDR, "WETH", ROUTER_ADDR), ]); expect(detail(result, "Paid to").value).toBe(UNDECODED); }); @@ -2443,7 +2650,11 @@ describe("uniswap decoder", () => { V4_EXACT_OUT_600, V2_EXACT_OUT_400, ], - lines: [...UNKNOWN_USDT, ...UP_TO_400_USDT], + lines: [ + ...UNKNOWN_USDT, + ...UP_TO_400_USDT, + ...ONE_USDT_LINES(OTHER_ADDR), + ], }, { kind: "a V4 step whose swap action cannot be read, with a SETTLE of WETH by the user, then a V2 exact-out of at most 400 USDT", @@ -3069,9 +3280,7 @@ describe("uniswap decoder", () => { ); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result.name).toBe("Uniswap Swap"); - expect(detail(result, "Token In").address).toBe( - USDT_ADDR.toLowerCase(), - ); + expect(detail(result, "Token In").address).toBe(USDT_ADDR); expect(detail(result, "Amount").value).toBe("1000.0000 USDT"); expect(detail(result, "Token Out").value).toBe( "Unknown (not named in the calldata)", @@ -3129,9 +3338,7 @@ describe("uniswap decoder", () => { "Unknown (not named in the calldata)", ); expect(detail(result, "Amount")).toBeUndefined(); - expect(detail(result, "Token Out").address).toBe( - USDT_ADDR.toLowerCase(), - ); + expect(detail(result, "Token Out").address).toBe(USDT_ADDR); expect(detail(result, "Min. received").value).toBe("990.0000 USDT"); }); diff --git a/tests/uniswapRecipient.test.js b/tests/uniswapRecipient.test.js index 2b92175..a152bc9 100644 --- a/tests/uniswapRecipient.test.js +++ b/tests/uniswapRecipient.test.js @@ -2809,7 +2809,7 @@ describe("the allow-revert flag", () => { 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", + "on $router, a PERMIT2_PERMIT with the flag is listed as 'Permit2 Permit$mark', and the swap's lines read as without the flag", ({ address, mark }) => { const steps = [ permit2Permit(USDT, USDT_2000), @@ -2824,9 +2824,24 @@ describe("the allow-revert flag", () => { 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)); + // The permit names the router at ROUTER as its spender, so its + // amount stands as the Amount figure only there, and only without + // the flag. With the flag it stands on no router and gets lines + // of its own, and the swap step names Token In: the same token, + // its address written the same way. + const permitLines = [ + "Permit2 allowance", + "Allowance token", + "Allowance spender", + ]; + const swapLines = (r) => + r.details.filter( + (d) => !["Steps", ...permitLines].includes(d.label), + ); + expect(swapLines(withFlag)).toEqual(swapLines(plain)); + expect( + labels(withFlag).filter((l) => permitLines.includes(l)), + ).toEqual(permitLines); expect(detail(withFlag, "Min. received").value).toBe("1.0000 WETH"); }, );