From cce6640d23f8782fd984cb582065cf3abefb6f1f Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 27 Oct 2026 00:13:54 +0000 Subject: [PATCH] fix: the swap decoder names where a WRAP_ETH pays and shows a V4 step's ETH as a maximum when the router may hold ETH (closes #665) A `WRAP_ETH` that sets the input side and pays its WETH to anyone but the router names that recipient on its `Moved to` line. V4 pays a `SETTLE` of ETH from the router's own ETH, whichever payer it names. When an earlier step may leave ETH in the router, the ETH that the user pays a later V4 step gets lines of its own. Their `Moved from the sender` line reads `Up to `, or `Unknown (could not be decoded)` when the amount is not known. The decoder never subtracts the router's ETH, so no line shows less ETH than can leave the wallet. Without such an earlier step, the lines read as before. Model: opus-5-5 --- README.md | 158 ++++++++++---------- src/shared/uniswap.js | 103 +++++++++---- tests/uniswap.test.js | 335 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 493 insertions(+), 103 deletions(-) diff --git a/README.md b/README.md index d5a5221..cb25065 100644 --- a/README.md +++ b/README.md @@ -1265,7 +1265,9 @@ see Transaction Decoding), do not always read as a number. They can read: the same words. `Unlimited`, `All available (V4 open delta)` and `Whatever an earlier step sent to the pair (V2 already paid)` keep their wording. When a V2 or V3 exact-out step sets `Min. received`, that line shows - its `amountOut`, the exact amount it buys. + its `amountOut`, the exact amount it buys. When the router may hold ETH from + an earlier step, the `Moved from the sender` line of ETH that the user pays a + V4 step also reads `Up to ` (see Transaction Decoding). - `All available (V4 open delta)`: the swap's `Amount` line, when the amount it shows is a V4 exact-in `amountIn` of zero. V4 reads that zero as "use the whole open delta", so the calldata states no quantity. Transaction Decoding @@ -3477,81 +3479,89 @@ Currently supported: of any amount but `OPEN_DELTA` pays that amount, and a `SETTLE` of `OPEN_DELTA` or a `SETTLE_ALL` pays what the step owes, as above. So a V4 step that swaps 400 USDT, with a `SETTLE` of 5,000 USDT paid by the user and the - rest taken to another address, reads `5000.0000 USDT`. A later step paid from - the router spends what the router holds and takes nothing, and so does a V2 - exact-in whose `amountIn` is `ALREADY_PAID`. A later take of `Token In` is - 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. 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 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 + rest taken to another address, reads `5000.0000 USDT`. V4 pays a `SETTLE` of + ETH from the router's own ETH, whichever payer it names. So when the router + may hold ETH from an earlier step (see above), the ETH that the user pays a V4 + step gets lines of its own (see below). A later step paid from the router + spends what the router holds and takes nothing, and so does a V2 exact-in + whose `amountIn` is `ALREADY_PAID`. A later take of `Token In` is 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. 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 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); one whose amount the decoder + does not know; and one of ETH that the user pays a V4 step when the router may + hold ETH. 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 - 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 - input or one of whose `SETTLE`s or `SETTLE_ALL`s it cannot read, which the - router still runs; a V2 or V3 exact-in or exact-out step or a - `PERMIT2_TRANSFER_FROM` whose input it cannot read, such as a V3 step whose - path is shorter than one token address or has bytes after its last hop; every - `PERMIT2_TRANSFER_FROM_BATCH`; every `EXECUTE_SUB_PLAN`, wherever it stands; - and every step it fails on. Each such step gives one `Moved from the sender` - line among those, reading `Unknown (could not be decoded)`. That line, or any - line for a token the decoder does not know, leaves no `Amount` line, whose - figure might be too low, and such a step before a `PERMIT2_PERMIT` keeps the - permit's amount from standing. Then each `PERMIT2_TRANSFER_FROM` but one into - the router, in step order, gives three lines: `Moved from the sender`, the - amount it moves, for example `1000000.0000 USDT`; `Token moved`, giving the - token's address as the `Token In` line does; and `Moved to`, which reads + `Moved to`, which reads `The swap step that spends it`. For ETH that the user + pays a V4 step when the router may hold ETH, `Moved from the sender` reads + `Up to ` for a known amount. For a `WRAP_ETH` that pays its WETH to + anyone but the router, `Moved to` names that recipient instead: `The sender` + for `address(1)`, and otherwise the address. 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 input or one of + whose `SETTLE`s or `SETTLE_ALL`s it cannot read, which the router still runs; + a V2 or V3 exact-in or exact-out step or a `PERMIT2_TRANSFER_FROM` whose input + it cannot read, such as a V3 step whose path is shorter than one token address + or has bytes after its last hop; every `PERMIT2_TRANSFER_FROM_BATCH`; every + `EXECUTE_SUB_PLAN`, wherever it stands; and every step it fails on. Each such + step gives one `Moved from the sender` line among those, reading + `Unknown (could not be decoded)`. That line, or any line for a token the + decoder does not know, leaves no `Amount` line, whose figure might be too low, + and such a step before a `PERMIT2_PERMIT` keeps the permit's amount from + standing. Then each `PERMIT2_TRANSFER_FROM` but one into the router, in step + order, gives three lines: `Moved from the sender`, the amount it moves, for + example `1000000.0000 USDT`; `Token moved`, giving the token's address as the + `Token In` line does; and `Moved to`, which reads `The V2 pair the next swap spends` for a hop and `The sender` for `address(1)` 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 diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index 2a00fff..d48440e 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -1244,6 +1244,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // comes from the transaction's value. A step paid from the router // takes nothing from the sender. let inputPaidByUser = false; + // The recipient of a WRAP_ETH that set the input side, as written, + // when it is not the router; otherwise null. That WRAP_ETH pays its + // WETH there, not to a swap step (see the Moved to lines). + let wrapEthRecipient = null; // The last PERMIT2_PERMIT this decoder read before a step set the // input side, or null (see permitStands after the loop). let permit = null; @@ -1336,15 +1340,18 @@ function decode(data, toAddress, chainId, sources, fromAddress) { }; // What the steps take from the sender beyond the amount of the step - // that sets the input side, as { token, amount }, in step order. - // After the loop each joins the Amount line or gets lines of its own - // (see permitStands). + // that sets the input side, as { token, amount, atMost }, in step + // order. After the loop each joins the Amount line or gets lines of + // its own (see permitStands). A take with `atMost` set takes at most + // its amount, and always gets lines of its own. const userPaid = []; // A swap step the user pays for (`payerIsUser` true) takes `amount` - // of `token` from the sender. A V2 exact-in of ALREADY_PAID takes - // nothing. - const addUserPaid = (token, amount) => { - if (amount !== ALREADY_PAID) userPaid.push({ token, amount }); + // of `token` from the sender, or at most that when `atMost` is set. A + // V2 exact-in of ALREADY_PAID takes nothing. + const addUserPaid = (token, amount, atMost = false) => { + if (amount !== ALREADY_PAID) { + userPaid.push({ token, amount, atMost }); + } }; // A step may take from the sender what this decoder did not read: a // V4 step whose input or one of whose SETTLEs or SETTLE_ALLs it @@ -1787,6 +1794,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) { w.amount, ); inputPaidByUser = true; + if (!isRouter(w.recipient)) { + wrapEthRecipient = w.recipient; + } } addPayee(w.recipient); // CONTRACT_BALANCE wraps all the router's ETH. @@ -1815,6 +1825,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } else if (command === "V4_SWAP") { const v4 = decodeV4Swap(inputs[i]); const inputSetBefore = inputEstablished; + // V4Router pays a SETTLE of ETH from the router's own + // ETH, whichever payer it names. When the router may hold + // ETH from an earlier step, that ETH may pay some of the + // ETH that the user pays this step. The sender then pays + // at most the step's amount. + const routerMayHoldEth = heldByRouter.has( + "0x0000000000000000000000000000000000000000", + ); if (v4) { // Each SETTLE paid from the router spends the amount // decodeV4Swap() says it pays. @@ -1905,7 +1923,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) { if (v4) { for (const p of v4.paidByUser) { if (setInputHere && p === paidIn) continue; - addUserPaid(p.currency, p.amount); + addUserPaid( + p.currency, + p.amount, + routerMayHoldEth && + p.currency === + "0x0000000000000000000000000000000000000000", + ); } } if (!v4 || v4.settleUnread) { @@ -2016,7 +2040,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // being of another token or coming after an amount that is not a // quantity (a V4 open delta, ALREADY_PAID, or none stated), gets // lines of its own. One whose amount is not known also leaves the - // total unknown, with no figure. + // total unknown, with no figure. A take of at most its amount (see + // userPaid) is never added, and gets lines of its own too. // // 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 @@ -2051,14 +2076,17 @@ function decode(data, toAddress, chainId, sources, fromAddress) { permit.beforeEveryTake && !stepsUnread.some((step) => step > permit.step); // What the steps take that the Amount line does not give, as - // { token, amount }. A null token is a step this decoder did not read - // (see spentUnread()), or a token it does not know. + // { token, amount, atMost, recipient }. A null token is a step this + // decoder did not read (see spentUnread()), or a token it does not + // know. `atMost` is as in `userPaid`. Only the take of a WRAP_ETH that + // pays elsewhere has a recipient (see wrapEthRecipient). const spentFromSender = []; let total = null; - // The takes from the sender that `total` adds up, each a quantity, as - // { token, amount }. When there is no Amount line, each gets lines of - // its own (see below). The amount of a step that set the input side - // and is paid from the router counts in `total` but is not among them. + // The takes from the sender that `total` adds up, each a quantity, in + // the shape of `spentFromSender`. When there is no Amount line, each + // gets lines of its own (see below). The amount of a step that set + // the input side and is paid from the router counts in `total` but is + // not among them. const inTotal = []; // Whether the step that set the input side counts as a take from the // sender, which may get lines of its own. The amount it stated counts @@ -2073,7 +2101,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) { if (!permitStands || sameAddress(inputToken, permit.token)) { total = inputAmount; if (typeof inputAmount === "bigint" && inputPaidByUser) { - inTotal.push({ token: inputToken, amount: inputAmount }); + inTotal.push({ + token: inputToken, + amount: inputAmount, + recipient: wrapEthRecipient, + }); } if (hasFirstTake && !present(inputAmount)) { firstTakeUnstated = { token: inputToken, amount: null }; @@ -2081,7 +2113,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } else if (hasFirstTake) { // The step that set the input side spent a token other than the // permit's, or one not known, so its amount gets lines of its own. - spentFromSender.push({ token: inputToken, amount: inputAmount }); + spentFromSender.push({ + token: inputToken, + amount: inputAmount, + recipient: wrapEthRecipient, + }); } if (permitStands) inputToken = permit.token; for (const t of userPaid) { @@ -2089,6 +2125,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { if (ofTokenIn && permitStands) continue; if ( ofTokenIn && + !t.atMost && typeof total === "bigint" && typeof t.amount === "bigint" ) { @@ -2193,8 +2230,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // V2's already-paid zero, neither of which is a quantity at all (see // OPEN_DELTA and ALREADY_PAID), and an unbounded permit. Those two // tests come first — the sentinels are not bigints and cannot be - // compared with one. - const spentText = (amount, info) => { + // compared with one. `atMost` says the amount is a maximum (see + // userPaid). + const spentText = (amount, info, atMost = false) => { if (amount === OPEN_DELTA) { return { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT }; } @@ -2207,7 +2245,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { if (amount >= MAX_UINT160) { return { raw: "Unlimited", display: "Unlimited" }; } - if (hasV2OrV3ExactOut) { + if (hasV2OrV3ExactOut || atMost) { // A V2 or V3 exact-out swap spends at most this figure, // whichever steps gave it (an amountInMax, the WRAP_ETH of a // swap paid in ETH, a permit, or several steps' total), so it @@ -2260,20 +2298,33 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // What steps take from the sender beyond the Amount line, in the shape // of the lines below for a PERMIT2_TRANSFER_FROM. An amount this // decoder does not know is said so, and a step it did not read gets - // that one line alone. + // that one line alone. A take with no recipient goes to the swap step + // that spends it. A take with a recipient goes to that recipient; + // `MSG_SENDER` means the sender. for (const s of spentFromSender) { details.push({ label: "Moved from the sender", value: present(s.amount) - ? spentText(s.amount, tokenInfo(s.token, sources)).display + ? spentText(s.amount, tokenInfo(s.token, sources), s.atMost) + .display : UNDECODED_CURRENCY, }); if (present(s.token)) { details.push(tokenMovedLine("Token moved", s.token, sources)); - details.push({ - label: "Moved to", - value: "The swap step that spends it", - }); + if (!present(s.recipient)) { + details.push({ + label: "Moved to", + value: "The swap step that spends it", + }); + } else if (s.recipient.toLowerCase() === MSG_SENDER) { + details.push({ label: "Moved to", value: "The sender" }); + } else { + details.push({ + label: "Moved to", + value: s.recipient, + address: s.recipient, + }); + } } } diff --git a/tests/uniswap.test.js b/tests/uniswap.test.js index c0e3b64..971fc18 100644 --- a/tests/uniswap.test.js +++ b/tests/uniswap.test.js @@ -1428,8 +1428,8 @@ describe("uniswap decoder", () => { ]; // execute() of these command bytes and inputs, decoded as sent by - // the user. - function decodeSteps(commands, inputs) { + // the user to `router`. + function decodeSteps(commands, inputs, router = ROUTER_ADDR) { const data = buildExecute( solidityPacked( commands.map(() => "uint8"), @@ -1438,7 +1438,7 @@ describe("uniswap decoder", () => { inputs, 9999999999n, ); - return uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); + return uniswap.decode(data, router, "0x1", {}, USER_ADDR); } // The first step: a V3 exact-in of 600 USDT for USDC, paid by the @@ -2424,6 +2424,335 @@ describe("uniswap decoder", () => { expect(linesBetweenTokens(result)).toEqual(lines); }); + // https://git.eeqj.de/sneak/AutistMask/issues/665 — a WRAP_ETH that + // sets the input side and pays its WETH to anyone but the router + // names that recipient on its Moved to line. On next each row read + // The swap step that spends it. + const ONE_ETH = 1000000000000000000n; + const MSG_SENDER = "0x0000000000000000000000000000000000000001"; + // The lines of a WRAP_ETH of 1 ETH whose Moved to line reads `to`. + const wrapLines = (to) => [ + ["Moved from the sender", "1.0000 ETH"], + ["Token moved", "ETH"], + ["Moved to", to], + ]; + test.each([ + { + kind: "a WRAP_ETH of 1 ETH to another address, then an EXECUTE_SUB_PLAN", + commands: [0x0b, 0x21], + inputs: [encodeWrapEth(OTHER_ADDR, ONE_ETH), SUB_PLAN], + lines: [ + ...wrapLines(OTHER_ADDR), + ["Moved from the sender", UNDECODED], + ], + }, + { + kind: "a WRAP_ETH of 1 ETH to address(1), then an EXECUTE_SUB_PLAN", + commands: [0x0b, 0x21], + inputs: [encodeWrapEth(MSG_SENDER, ONE_ETH), SUB_PLAN], + lines: [ + ...wrapLines("The sender"), + ["Moved from the sender", UNDECODED], + ], + }, + { + kind: "a PERMIT2_PERMIT of 5,000 USDT to the router, a WRAP_ETH of 1 ETH to another address, then a V3 exact-in of 600 USDT", + commands: [0x0a, 0x0b, 0x00], + inputs: [ + encodePermit2(USDT_ADDR, 5000000000n, ROUTER_ADDR), + encodeWrapEth(OTHER_ADDR, ONE_ETH), + V3_600, + ], + lines: [["Amount", "5000.0000 USDT"], ...wrapLines(OTHER_ADDR)], + }, + ])("$kind", ({ commands, inputs, lines }) => { + const result = decodeSteps(commands, inputs); + expect(linesBetweenTokens(result)).toEqual(lines); + }); + + // https://git.eeqj.de/sneak/AutistMask/issues/665 — V4Router pays a + // SETTLE of ETH from the router's own ETH, whichever payer it names. + // After an earlier step that may leave ETH in the router, the ETH that + // the user pays a V4 step reads as a maximum, on lines of its own. + // Every row reads at least the ETH that can leave the wallet. The two + // rows marked "reads as on next" read the same on next. On next every + // other row added that ETH to the Amount line, or read it as an exact + // amount. + // + // ADDRESS_THIS is the router on every release. + const ADDRESS_THIS = "0x0000000000000000000000000000000000000002"; + const WRAP_INTO_ROUTER = encodeWrapEth(ADDRESS_THIS, ONE_ETH); + // UNWRAP_WETH has the layout of WRAP_ETH. + const UNWRAP_INTO_ROUTER = encodeWrapEth(ADDRESS_THIS, 0n); + // A V4 step swapping `amount` of ETH for USDC, with a SETTLE of + // OPEN_DELTA by the user. + const v4EthForUsdc = (amount) => + encodeV4Swap( + new Uint8Array([ + V4_SWAP_EXACT_IN_SINGLE_ID, + V4_SETTLE_ID, + V4_TAKE_ALL_ID, + ]), + [ + encodeV4ExactInSingle(ZERO_ADDR, USDC_ADDR, amount, 1n), + settle(ZERO_ADDR, 0n, true), + encodeV4TakeAll(USDC_ADDR, 0n), + ], + ); + const V4_ONE_ETH = v4EthForUsdc(ONE_ETH); + // A TRANSFER or PAY_PORTION of `amount` of `token` to another + // address. + const payOut = (token, amount) => + coder.encode( + ["address", "address", "uint256"], + [token, OTHER_ADDR, amount], + ); + // An ACROSS_V4_DEPOSIT_V3 of `amount` of ETH (`useNative` set), from + // the sender to the sender on chain id 8453. + const acrossEth = (amount) => + coder.encode( + [ + "tuple(address,address,address,address,uint256,uint256,uint256,address,uint32,uint32,uint32,bytes,bool)", + ], + [ + [ + USER_ADDR, + USER_ADDR, + WETH_ADDR, + WETH_ADDR, + amount, + 1n, + 8453n, + ZERO_ADDR, + 0, + 0, + 0, + "0x", + true, + ], + ], + ); + // The lines of ETH the sender pays a V4 step, of `amount`. + const ethLines = (amount) => [ + ["Moved from the sender", amount], + ["Token moved", "ETH"], + ["Moved to", "The swap step that spends it"], + ]; + test.each([ + { + kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of 1 ETH that the user settles, reads as on next", + commands: [0x0b, 0x10], + inputs: [WRAP_INTO_ROUTER, V4_ONE_ETH], + lines: [["Amount", "2.0000 ETH"]], + }, + { + kind: "a WRAP_ETH of 1 ETH into the router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles", + commands: [0x0b, 0x0c, 0x10], + inputs: [WRAP_INTO_ROUTER, UNWRAP_INTO_ROUTER, V4_ONE_ETH], + lines: [ + ["Amount", "1.0000 ETH"], + ...ethLines("Up to 1.0000 ETH"), + ], + }, + { + kind: "the same with a TRANSFER of 1 ETH to another address after the UNWRAP_WETH", + commands: [0x0b, 0x0c, 0x05, 0x10], + inputs: [ + WRAP_INTO_ROUTER, + UNWRAP_INTO_ROUTER, + payOut(ZERO_ADDR, ONE_ETH), + V4_ONE_ETH, + ], + lines: [ + ["Amount", "1.0000 ETH"], + ...ethLines("Up to 1.0000 ETH"), + ], + }, + { + kind: "the same with a PAY_PORTION of 5,000 bips of WETH to another address before the UNWRAP_WETH", + commands: [0x0b, 0x06, 0x0c, 0x10], + inputs: [ + WRAP_INTO_ROUTER, + payOut(WETH_ADDR, 5000n), + UNWRAP_INTO_ROUTER, + V4_ONE_ETH, + ], + lines: [ + ["Amount", "1.0000 ETH"], + ...ethLines("Up to 1.0000 ETH"), + ], + }, + { + kind: "the same with a WRAP_ETH of 1 ETH to another address after the UNWRAP_WETH", + commands: [0x0b, 0x0c, 0x0b, 0x10], + inputs: [ + WRAP_INTO_ROUTER, + UNWRAP_INTO_ROUTER, + encodeWrapEth(OTHER_ADDR, ONE_ETH), + V4_ONE_ETH, + ], + lines: [ + ["Amount", "1.0000 ETH"], + ...ethLines("Up to 1.0000 ETH"), + ], + }, + { + kind: "the same on UniversalRouterV2_1_2 with an ACROSS_V4_DEPOSIT_V3 of 1 ETH after the UNWRAP_WETH", + router: ROUTER_V2_1_2_ADDR, + commands: [0x0b, 0x0c, 0x40, 0x10], + inputs: [ + WRAP_INTO_ROUTER, + UNWRAP_INTO_ROUTER, + acrossEth(ONE_ETH), + V4_ONE_ETH, + ], + lines: [ + ["Amount", "1.0000 ETH"], + ...ethLines("Up to 1.0000 ETH"), + ], + }, + { + kind: "a V3 exact-in of 600 USDT that the user pays, a WRAP_ETH of 1 ETH into the router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles", + commands: [0x00, 0x0b, 0x0c, 0x10], + inputs: [ + V3_600, + WRAP_INTO_ROUTER, + UNWRAP_INTO_ROUTER, + V4_ONE_ETH, + ], + lines: [ + ["Amount", "600.0000 USDT"], + ...ethLines("Up to 1.0000 ETH"), + ], + }, + { + kind: "a V3 exact-in of 600 USDT for WETH paid to the router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles", + commands: [0x00, 0x0c, 0x10], + inputs: [ + encodeV3SwapExactIn(ADDRESS_THIS, USDT_600, 1n, [ + USDT_ADDR, + WETH_ADDR, + ]), + UNWRAP_INTO_ROUTER, + V4_ONE_ETH, + ], + lines: [ + ["Amount", "600.0000 USDT"], + ...ethLines("Up to 1.0000 ETH"), + ], + }, + { + kind: "a V4 swap of 600 USDT that takes ETH into the router, then a V4 swap of 5 ETH that the user settles", + commands: [0x10, 0x10], + inputs: [ + encodeV4Swap( + new Uint8Array([ + V4_SWAP_EXACT_IN_SINGLE_ID, + V4_SETTLE_ID, + V4_TAKE_ID, + ]), + [ + encodeV4ExactInSingle( + USDT_ADDR, + ZERO_ADDR, + USDT_600, + 1n, + ), + settle(USDT_ADDR, 0n, true), + take(ZERO_ADDR, ADDRESS_THIS, 0n), + ], + ), + v4EthForUsdc(5n * ONE_ETH), + ], + lines: [ + ["Amount", "600.0000 USDT"], + ...ethLines("Up to 5.0000 ETH"), + ], + }, + { + kind: "a WRAP_ETH of 1 ETH into the router, an UNWRAP_WETH into the router, then a V4 exact-out paid in ETH with a SETTLE_ALL of at most 3 ETH, reads as on next", + commands: [0x0b, 0x0c, 0x10], + inputs: [ + WRAP_INTO_ROUTER, + UNWRAP_INTO_ROUTER, + encodeV4Swap( + new Uint8Array([ + V4_SWAP_EXACT_OUT_SINGLE_ID, + V4_SETTLE_ALL_ID, + V4_TAKE_ALL_ID, + ]), + [ + encodeV4ExactOutSingle( + ZERO_ADDR, + USDC_ADDR, + 390000000n, + 3n * ONE_ETH, + ), + encodeV4SettleAll(ZERO_ADDR, 3n * ONE_ETH), + encodeV4TakeAll(USDC_ADDR, 0n), + ], + ), + ], + lines: [ + ...wrapLines("The swap step that spends it"), + ...ethLines(UNDECODED), + ], + }, + { + kind: "a WRAP_ETH of 1 ETH into the router, a V3 exact-out of at most 1 WETH paid from the router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles", + commands: [0x0b, 0x01, 0x0c, 0x10], + inputs: [ + WRAP_INTO_ROUTER, + encodeV3SwapExactOut( + USER_ADDR, + 390000000n, + ONE_ETH, + [USDC_ADDR, WETH_ADDR], + false, + ), + UNWRAP_INTO_ROUTER, + V4_ONE_ETH, + ], + lines: [ + ["Amount", "Up to 1.0000 ETH"], + ...ethLines("Up to 1.0000 ETH"), + ], + }, + { + kind: "a WRAP_ETH of 1 ETH into the router, a V4 exact-out paid from the router's WETH, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles", + commands: [0x0b, 0x10, 0x0c, 0x10], + inputs: [ + WRAP_INTO_ROUTER, + encodeV4Swap( + new Uint8Array([ + V4_SWAP_EXACT_OUT_SINGLE_ID, + V4_SETTLE_ID, + V4_TAKE_ALL_ID, + ]), + [ + encodeV4ExactOutSingle( + WETH_ADDR, + USDC_ADDR, + 390000000n, + ONE_ETH, + ), + settle(WETH_ADDR, 0n, false), + encodeV4TakeAll(USDC_ADDR, 0n), + ], + ), + UNWRAP_INTO_ROUTER, + V4_ONE_ETH, + ], + lines: [ + ["Amount", "1.0000 ETH"], + ...ethLines("Up to 1.0000 ETH"), + ], + }, + ])("$kind", ({ router, commands, inputs, lines }) => { + const result = decodeSteps(commands, inputs, router); + expect(linesBetweenTokens(result)).toEqual(lines); + }); + // A V3_SWAP_EXACT_OUT the user pays for, of at most `amountInMax` // USDT for USDC. const v3ExactOut = (amountInMax) => -- 2.54.0