diff --git a/README.md b/README.md index 260306c..db62536 100644 --- a/README.md +++ b/README.md @@ -2422,10 +2422,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 @@ -3215,37 +3216,37 @@ Currently supported: 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 + decoder reads before the step that sets the input side, 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` 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. 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 `Moved to`, which reads `The swap step that spends it`. A token the decoder @@ -3268,7 +3269,20 @@ 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 + where it goes, and so its amount is shown once. A `PERMIT2_PERMIT` after the + step that sets the input side names neither `Token In` nor the `Amount` + figure, which keep that step's token and the total: a V3 exact-in of 600 USDT + followed by a permit of 5,000 DAI reads `Swap USDT → USDC` with + `Amount 600.0000 USDT`. 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. So does one whose + spender is not the router, or that the router may let fail. 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 diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index 6b2683e..403ee97 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)) { @@ -1208,9 +1209,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 +1274,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, the last one read before a step set the input side 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 +1472,30 @@ 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); + } + // After a step set the input side, a permit names + // neither Token In nor the Amount figure: the swap's own + // input token and amount stay on those lines. + 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 +1952,22 @@ 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 + // The last PERMIT2_PERMIT read before a step set the input side 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 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. 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). + // step leaves both sides unknown (below). A PERMIT2_PERMIT after a + // step set the input side counts among the PERMIT2_PERMITs here. + // Every PERMIT2_PERMIT but one whose amount stands gets lines of its + // own below. const stepCommands = Array.from( commandsBytes, (b) => commands[b & commandTypeMask], @@ -2181,6 +2198,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..9031ed8 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, @@ -1406,6 +1406,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 +1719,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 +1728,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 +1750,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 +2045,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 +2071,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 +2092,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 +2137,7 @@ describe("uniswap decoder", () => { ); expect(linesBetweenTokens(result)).toEqual([ ["Amount", "10000.0000 USDT"], + ...USDT_600_LINES, ]); }); @@ -2141,6 +2183,7 @@ describe("uniswap decoder", () => { ); expect(linesBetweenTokens(result)).toEqual([ ["Amount", "10000.0000 USDT"], + ...USDT_600_LINES, ]); }, ); @@ -2168,8 +2211,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 +2245,152 @@ 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. + // The swap's own token and amount now stay on those lines. That + // permit, and every other whose amount does not stand as the Amount + // figure, gets lines of its own; a permit before the swap steps still + // names Token In. + 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 shows the swap's 600 USDT and the permit's own lines", + ({ commands, inputs, lines }) => { + const result = decodeSteps(commands, inputs); + expect(result.name).toBe("Swap USDT → USDC"); + expect( + detail(result, "Token In").address.toLowerCase(), + ).toBe(USDT_ADDR.toLowerCase()); + expect(linesBetweenTokens(result)).toEqual([ + ["Amount", "600.0000 USDT"], + ...lines, + ]); + expect(detail(result, "Amount").rawValue).toBe("600.0000"); + }, + ); + + // The permit before the swap steps still names Token In. With a + // second permit its amount does not stand, 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 +2436,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 +2452,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 +2623,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", diff --git a/tests/uniswapRecipient.test.js b/tests/uniswapRecipient.test.js index 2b92175..73f9c07 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. + const permitLines = [ + "Permit2 allowance", + "Allowance token", + "Allowance spender", + ]; + const swapLines = (r) => + r.details.filter( + (d) => + d.label !== "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"); }, );