From c56179abffb06bf7a1a3625be4d67e4fab897295 Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 3 Nov 2026 13:18:49 +0000 Subject: [PATCH] fix: a WRAP_ETH says where its ETH goes (closes #747) A WRAP_ETH into the router now always reads `Moved to: The router`. That is true whichever step then takes the WETH. Other takes keep `The swap step that spends it`. A later WRAP_ETH of CONTRACT_BALANCE to anyone but the router now gets a `Moved from the sender` line reading `All the ETH the transaction carries`, and `Moved to` names the recipient. It gets none after an UNWRAP_WETH into the router, or while the router holds ETH from a V4 step. README states both rules. Model: opus-5-5 --- README.md | 22 ++- src/shared/uniswap.js | 87 +++++++--- tests/uniswap.test.js | 377 +++++++++++++++++++++++++++++++++++++++--- 3 files changed, 430 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 2f6a7c8..fa3c430 100644 --- a/README.md +++ b/README.md @@ -1414,15 +1414,19 @@ when both tokens are known. The lines are: the sender that the `Amount` line does not include. The same three lines show what a `PERMIT2_TRANSFER_FROM` moves and where. A `WRAP_ETH` can pay its WETH to a recipient other than the router. When its amount gets these lines, its - `Moved to` line names that recipient, or reads `The sender`. A `WRAP_ETH` - after the step that sets `Token In` takes the amount it states from the - sender. When it pays anyone but the router, that amount gets these lines. When - it pays the router, that amount joins the `Amount` line if `Token In` is ETH, - and gets these lines if not. A later `WRAP_ETH` of `CONTRACT_BALANCE` states - no amount, so it gets no lines. ETH that the sender pays a V4 step or a - `WRAP_ETH` of a stated amount can get these three lines when the router may - hold ETH from an earlier step. When the decoder knows the amount, its - `Moved from the sender` line reads `Up to` and the amount. + `Moved to` line names that recipient, or reads `The sender`. For a `WRAP_ETH` + into the router, `Moved to` reads `The router`. A `WRAP_ETH` after the step + that sets `Token In` takes the amount it states from the sender. When it pays + anyone but the router, that amount gets these lines. When it pays the router, + that amount joins the `Amount` line if `Token In` is ETH, and gets these lines + if not. A later `WRAP_ETH` of `CONTRACT_BALANCE` to anyone but the router gets + these lines. It gets none after an `UNWRAP_WETH` into the router, or while the + router holds ETH from a V4 step. When it gets them, its + `Moved from the sender` line reads `All the ETH the transaction carries`. ETH + that the sender pays a V4 step or a `WRAP_ETH` of a stated amount can get + these three lines when the router may hold ETH from an earlier step. When the + decoder knows the amount, its `Moved from the sender` line reads `Up to` and + the amount. - `Permit2 allowance`, `Allowance token` and `Allowance spender`: each allowance that a `PERMIT2_PERMIT` or `PERMIT2_PERMIT_BATCH` grants, unless the permit's amount already stands as the `Amount` figure. diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index 21c4935..ec94823 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -404,6 +404,11 @@ const OPEN_DELTA = Symbol("v4-open-delta"); // that nothing is swapped. const ALREADY_PAID = Symbol("v2-already-paid"); +// A later WRAP_ETH of CONTRACT_BALANCE wraps all the ETH the router holds, and +// so all the ETH the transaction carries. The calldata states no quantity for +// it. The approval screen's Value line gives that ETH. +const TRANSACTION_ETH = Symbol("transaction-eth"); + // The router's whole-balance amount, universal-router // `ActionConstants.CONTRACT_BALANCE`: a V2 exact-in given it as `amountIn` // pays the pair all the router holds of the input token. @@ -416,6 +421,7 @@ const CONTRACT_BALANCE = 2n ** 255n; const OPEN_DELTA_AMOUNT = "All available (V4 open delta)"; const ALREADY_PAID_AMOUNT = "Whatever an earlier step sent to the pair (V2 already paid)"; +const TRANSACTION_ETH_AMOUNT = "All the ETH the transaction carries"; const NO_MINIMUM = "None (no minimum guaranteed)"; // Permit2 amounts are uint160; the maximum is Permit2's "unbounded". @@ -1267,9 +1273,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // ERC-20 token from the router takes none of that token 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). + // The recipient of a WRAP_ETH that set the input side, as written; + // otherwise null. That WRAP_ETH pays its WETH there (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). @@ -1289,6 +1295,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) { let minOutput = null; let outputEstablished = false; let hasUnwrapWeth = false; + // Set once an UNWRAP_WETH pays ETH to the router, which a later + // WRAP_ETH of CONTRACT_BALANCE then wraps. + let unwrappedIntoRouter = false; // Set when an UNWRAP_WETH pays out, as ETH, the output side's WETH, // which the router holds; cleared whenever the output side is set // again, or a later step may pay that ETH out as WETH (see after the @@ -1368,9 +1377,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // After the loop each joins the Amount line or gets lines of its own // (see permitStands). A take with `atMost` set may take less than // `amount`: it never joins the Amount line, and its line reads - // "Up to" and the amount. A take with a `recipient`, the ETH of a - // later WRAP_ETH that pays anyone but the router, never joins the - // Amount line either: its Moved to line names that recipient. + // "Up to" and the amount. A take with a `recipient` is the ETH of a + // later WRAP_ETH. If that recipient is not the router, the take never + // joins the Amount line either: its Moved to line names that recipient. const userPaid = []; // A swap step the user pays for (`payerIsUser` true) takes `amount` // of `token` from the sender, or at most that when `atMost` is set. A @@ -1906,9 +1915,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { w.amount, ); inputPaidByUser = true; - if (!isRouter(w.recipient)) { - wrapEthRecipient = w.recipient; - } + wrapEthRecipient = w.recipient; } else if ( w.amount > 0n && w.amount !== CONTRACT_BALANCE @@ -1916,17 +1923,33 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // The ETH of a stated amount comes from the // transaction's value. ETH an earlier step left in // the router may pay part of it, and the sender - // then pays at most that amount. CONTRACT_BALANCE - // states no amount to take. + // then pays at most that amount. userPaid.push({ token: "0x0000000000000000000000000000000000000000", amount: w.amount, atMost: heldByRouter.has( "0x0000000000000000000000000000000000000000", ), - recipient: isRouter(w.recipient) - ? null - : w.recipient, + recipient: w.recipient, + }); + } else if ( + w.amount === CONTRACT_BALANCE && + !isRouter(w.recipient) && + !unwrappedIntoRouter && + !heldByRouter.has( + "0x0000000000000000000000000000000000000000", + ) + ) { + // It pays the recipient all the ETH the transaction + // carries. It gets no lines after an UNWRAP_WETH + // into the router, or while the router holds ETH + // from a V4 step: Uniswap's routes pay back what + // their swap did not spend this way, and carry no + // ETH. + userPaid.push({ + token: "0x0000000000000000000000000000000000000000", + amount: TRANSACTION_ETH, + recipient: w.recipient, }); } addPayee(w.recipient); @@ -2169,6 +2192,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) { heldByRouter.has(w), ); WETH_ADDRESSES.forEach((w) => heldByRouter.delete(w)); + if (isRouter(u.recipient)) { + unwrappedIntoRouter = true; + } if (isRouter(u.recipient) && heldWeth) { heldByRouter.set( "0x0000000000000000000000000000000000000000", @@ -2266,8 +2292,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // What the steps take that the Amount line does not give, as // { 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 and userPaid). + // know. `atMost` is as in `userPaid`. Only the take of a WRAP_ETH has + // a recipient (see wrapEthRecipient and userPaid). const spentFromSender = []; let total = null; // The takes from the sender that `total` adds up, each a quantity, in @@ -2315,7 +2341,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { typeof total === "bigint" && typeof t.amount === "bigint" && !t.atMost && - !present(t.recipient) + (!present(t.recipient) || isRouter(t.recipient)) ) { total += t.amount; inTotal.push(t); @@ -2413,13 +2439,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } // The text of an amount the swap steps spend, for the Amount line and - // the lines for other tokens below it. Three amounts need no scale to - // describe and are named rather than formatted: V4's open delta and - // 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. `atMost` marks an amount that is only the most a - // take may be (see userPaid). + // the lines for other tokens below it. Four amounts need no scale to + // describe and are named rather than formatted: V4's open delta, V2's + // already-paid zero and the ETH the transaction carries, none of which + // is a quantity at all (see OPEN_DELTA, ALREADY_PAID and + // TRANSACTION_ETH), and an unbounded permit. Those three tests come + // first — the sentinels are not bigints and cannot be compared with + // one. `atMost` marks an amount that is only the most a take may be + // (see userPaid). const spentText = (amount, info, atMost) => { if (amount === OPEN_DELTA) { return { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT }; @@ -2430,6 +2457,12 @@ function decode(data, toAddress, chainId, sources, fromAddress) { display: ALREADY_PAID_AMOUNT, }; } + if (amount === TRANSACTION_ETH) { + return { + raw: TRANSACTION_ETH_AMOUNT, + display: TRANSACTION_ETH_AMOUNT, + }; + } if (amount >= MAX_UINT160) { return { raw: "Unlimited", display: "Unlimited" }; } @@ -2489,8 +2522,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // 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. 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. + // that spends it. A take with a recipient goes to that recipient: + // the router, the sender as `MSG_SENDER`, or an address. for (const s of spentFromSender) { details.push({ label: "Moved from the sender", @@ -2514,6 +2547,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) { label: "Moved to", value: "The swap step that spends it", }); + } else if (isRouter(s.recipient)) { + details.push({ label: "Moved to", value: "The router" }); } else if (s.recipient.toLowerCase() === MSG_SENDER) { details.push({ label: "Moved to", value: "The sender" }); } else { diff --git a/tests/uniswap.test.js b/tests/uniswap.test.js index 76fde00..adf8eb5 100644 --- a/tests/uniswap.test.js +++ b/tests/uniswap.test.js @@ -2474,10 +2474,11 @@ describe("uniswap decoder", () => { // 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. + // Every row reads at least the ETH that can leave the wallet. The row + // marked "reads as on next" reads the same on next. The row with a + // SETTLE_ALL of at most 3 ETH read that ETH as not known 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"; @@ -2625,7 +2626,7 @@ describe("uniswap decoder", () => { ], lines: [ ["Amount", "600.0000 USDT"], - ...wrapLines("The swap step that spends it"), + ...wrapLines("The router"), ...ethLines("Up to 1.0000 ETH"), ], }, @@ -2674,7 +2675,7 @@ describe("uniswap decoder", () => { ], }, { - 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", + 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", commands: [0x0b, 0x0c, 0x10], inputs: [ WRAP_INTO_ROUTER, @@ -2697,10 +2698,7 @@ describe("uniswap decoder", () => { ], ), ], - lines: [ - ...wrapLines("The swap step that spends it"), - ...ethLines(UNDECODED), - ], + lines: [...wrapLines("The router"), ...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", @@ -2761,8 +2759,8 @@ describe("uniswap decoder", () => { // does not set the input side takes the amount it states from the // sender. On next no line gave that ETH. The rows marked "reads as on // next" state no amount to take from the sender. Uniswap's own routes - // wrap CONTRACT_BALANCE after their first step, as the last two rows - // do. + // wrap CONTRACT_BALANCE into the router after their first step, as the + // last row does. const CONTRACT_BALANCE = 2n ** 255n; const WRAP_TO_OTHER = encodeWrapEth(OTHER_ADDR, ONE_ETH); test.each([ @@ -2787,7 +2785,7 @@ describe("uniswap decoder", () => { inputs: [V3_600, WRAP_INTO_ROUTER], lines: [ ["Amount", "600.0000 USDT"], - ...wrapLines("The swap step that spends it"), + ...wrapLines("The router"), ], }, { @@ -2796,7 +2794,7 @@ describe("uniswap decoder", () => { inputs: [V3_600, encodeWrapEth(ROUTER_ADDR, ONE_ETH)], lines: [ ["Amount", "600.0000 USDT"], - ...wrapLines("The swap step that spends it"), + ...wrapLines("The router"), ], }, { @@ -2839,7 +2837,9 @@ describe("uniswap decoder", () => { ], lines: [ ["Amount", "1.0000 ETH"], - ...ethLines("Up to 1.0000 ETH"), + ["Moved from the sender", "Up to 1.0000 ETH"], + ["Token moved", "ETH"], + ["Moved to", "The router"], ], }, { @@ -2877,12 +2877,6 @@ describe("uniswap decoder", () => { inputs: [V3_600, encodeWrapEth(OTHER_ADDR, 0n)], lines: [["Amount", "600.0000 USDT"]], }, - { - kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of CONTRACT_BALANCE to the sender, reads as on next", - commands: [0x00, 0x0b], - inputs: [V3_600, encodeWrapEth(MSG_SENDER, CONTRACT_BALANCE)], - lines: [["Amount", "600.0000 USDT"]], - }, { kind: "a V4 swap of 600 USDT that takes ETH into the router, a WRAP_ETH of CONTRACT_BALANCE into the router, then a V3 exact-in of it, reads as on next", commands: [0x10, 0x0b, 0x00], @@ -2923,6 +2917,347 @@ describe("uniswap decoder", () => { expect(linesBetweenTokens(result)).toEqual(lines); }); + // https://git.eeqj.de/sneak/AutistMask/issues/747 — a WRAP_ETH into the + // router pays its WETH to the router, whichever step then takes it. On + // next that Moved to line read The swap step that spends it in every + // row. + const SWAP_STEP = "The swap step that spends it"; + // Each pays 1 WETH from the router, for USDC, to the user. + const V3_WETH_FROM_ROUTER = coder.encode( + ["address", "uint256", "uint256", "bytes", "bool"], + [ + USER_ADDR, + ONE_ETH, + 1n, + encodeV3Path([WETH_ADDR, USDC_ADDR]), + false, + ], + ); + const V2_WETH_FROM_ROUTER = coder.encode( + ["address", "uint256", "uint256", "address[]", "bool"], + [USER_ADDR, ONE_ETH, 1n, [WETH_ADDR, USDC_ADDR], false], + ); + const V4_WETH_FROM_ROUTER = encodeV4Swap( + new Uint8Array([ + V4_SWAP_EXACT_IN_SINGLE_ID, + V4_SETTLE_ID, + V4_TAKE_ALL_ID, + ]), + [ + encodeV4ExactInSingle(WETH_ADDR, USDC_ADDR, ONE_ETH, 1n), + settle(WETH_ADDR, 0n, false), + encodeV4TakeAll(USDC_ADDR, 0n), + ], + ); + // SWEEP has the layout of TRANSFER, and its amount is a minimum. + const SWEEP_WETH = payOut(WETH_ADDR, 0n); + test.each([ + { + kind: "a V3 exact-in of 600 USDT, a WRAP_ETH of 1 ETH into the router, then a SWEEP of that WETH to another address", + commands: [0x00, 0x0b, 0x04], + inputs: [V3_600, WRAP_INTO_ROUTER, SWEEP_WETH], + lines: [ + ["Amount", "600.0000 USDT"], + ...wrapLines("The router"), + ], + }, + { + kind: "a V3 exact-in of 600 USDT, a WRAP_ETH of 1 ETH into the router, a SWEEP of that WETH to another address, then a V3 exact-in paid from the router's WETH", + commands: [0x00, 0x0b, 0x04, 0x00], + inputs: [ + V3_600, + WRAP_INTO_ROUTER, + SWEEP_WETH, + V3_WETH_FROM_ROUTER, + ], + lines: [ + ["Amount", "600.0000 USDT"], + ...wrapLines("The router"), + ], + }, + { + kind: "a V3 exact-in of 600 USDT, a WRAP_ETH of 1 ETH into the router, then a V3 exact-in of WETH that the user pays", + commands: [0x00, 0x0b, 0x00], + inputs: [ + V3_600, + WRAP_INTO_ROUTER, + encodeV3SwapExactIn(USER_ADDR, ONE_ETH, 1n, [ + WETH_ADDR, + USDC_ADDR, + ]), + ], + lines: [ + ["Amount", "600.0000 USDT"], + ...wrapLines("The router"), + ["Moved from the sender", "1.0000 WETH"], + ["Token moved", "WETH (" + WETH_ADDR + ")"], + ["Moved to", SWAP_STEP], + ], + }, + { + kind: "a V3 exact-in of 600 USDT, a WRAP_ETH of 1 ETH into the router, a V3 exact-in paid from the router's WETH, then another WRAP_ETH of 1 ETH into the router", + commands: [0x00, 0x0b, 0x00, 0x0b], + inputs: [ + V3_600, + WRAP_INTO_ROUTER, + V3_WETH_FROM_ROUTER, + WRAP_INTO_ROUTER, + ], + lines: [ + ["Amount", "600.0000 USDT"], + ...wrapLines("The router"), + ...wrapLines("The router"), + ], + }, + { + kind: "a V3 exact-in of 600 USDT, a WRAP_ETH of 1 ETH into the router, then a V3 exact-in paid from the router's WETH", + commands: [0x00, 0x0b, 0x00], + inputs: [V3_600, WRAP_INTO_ROUTER, V3_WETH_FROM_ROUTER], + lines: [ + ["Amount", "600.0000 USDT"], + ...wrapLines("The router"), + ], + }, + { + kind: "the same with a V2 exact-in paid from the router's WETH", + commands: [0x00, 0x0b, 0x08], + inputs: [V3_600, WRAP_INTO_ROUTER, V2_WETH_FROM_ROUTER], + lines: [ + ["Amount", "600.0000 USDT"], + ...wrapLines("The router"), + ], + }, + { + kind: "the same with a V3 exact-out paid from the router's WETH", + commands: [0x00, 0x0b, 0x01], + inputs: [ + V3_600, + WRAP_INTO_ROUTER, + encodeV3SwapExactOut( + USER_ADDR, + 390000000n, + ONE_ETH, + [USDC_ADDR, WETH_ADDR], + false, + ), + ], + lines: [ + ["Amount", "Up to 600.0000 USDT"], + ["Moved from the sender", "Up to 1.0000 ETH"], + ["Token moved", "ETH"], + ["Moved to", "The router"], + ], + }, + { + kind: "the same with a V4 swap that the router's WETH settles", + commands: [0x00, 0x0b, 0x10], + inputs: [V3_600, WRAP_INTO_ROUTER, V4_WETH_FROM_ROUTER], + lines: [ + ["Amount", "600.0000 USDT"], + ...wrapLines("The router"), + ], + }, + { + kind: "a WRAP_ETH of 1 ETH into the router that sets the input side, a SWEEP of that WETH to another address, then an EXECUTE_SUB_PLAN", + commands: [0x0b, 0x04, 0x21], + inputs: [WRAP_INTO_ROUTER, SWEEP_WETH, SUB_PLAN], + lines: [ + ...wrapLines("The router"), + ["Moved from the sender", UNDECODED], + ], + }, + { + kind: "a WRAP_ETH of 1 ETH into the router that sets the input side, a V3 exact-in paid from the router's WETH, then an EXECUTE_SUB_PLAN", + commands: [0x0b, 0x00, 0x21], + inputs: [WRAP_INTO_ROUTER, V3_WETH_FROM_ROUTER, SUB_PLAN], + lines: [ + ...wrapLines("The router"), + ["Moved from the sender", UNDECODED], + ], + }, + { + kind: "a PERMIT2_PERMIT of 5,000 USDT to the router, a WRAP_ETH of 1 ETH into the router, then a V3 exact-in of 600 USDT", + commands: [0x0a, 0x0b, 0x00], + inputs: [ + encodePermit2(USDT_ADDR, 5000000000n, ROUTER_ADDR), + WRAP_INTO_ROUTER, + V3_600, + ], + lines: [ + ["Amount", "5000.0000 USDT"], + ...wrapLines("The router"), + ], + }, + { + kind: "the same with a V3 exact-in paid from the router's WETH after the WRAP_ETH", + commands: [0x0a, 0x0b, 0x00, 0x00], + inputs: [ + encodePermit2(USDT_ADDR, 5000000000n, ROUTER_ADDR), + WRAP_INTO_ROUTER, + V3_WETH_FROM_ROUTER, + V3_600, + ], + lines: [ + ["Amount", "5000.0000 USDT"], + ...wrapLines("The router"), + ], + }, + ])("$kind", ({ commands, inputs, lines }) => { + const result = decodeSteps(commands, inputs); + expect(linesBetweenTokens(result)).toEqual(lines); + }); + + // https://git.eeqj.de/sneak/AutistMask/issues/747 — a later WRAP_ETH + // of CONTRACT_BALANCE wraps all the ETH the router holds. Unless an + // earlier step paid ETH to the router, that is the ETH the transaction + // carries. On next no line gave it. Into the router it gets no lines. + // It gets none after an UNWRAP_WETH into the router, or while the + // router holds ETH from a V4 step: Uniswap's own routes pay back what + // their swap did not spend this way, and carry no ETH. In the rows + // marked "reads as on next" that WRAP_ETH gets no lines. + const WRAP_ALL_TO_OTHER = encodeWrapEth(OTHER_ADDR, CONTRACT_BALANCE); + const WRAP_ALL_TO_SENDER = encodeWrapEth(MSG_SENDER, CONTRACT_BALANCE); + // The lines of such a WRAP_ETH whose Moved to line reads `to`. + const allEthLines = (to) => [ + ["Moved from the sender", "All the ETH the transaction carries"], + ["Token moved", "ETH"], + ["Moved to", to], + ]; + // The first steps of a route that spends WETH on a V4 pool of ETH: + // a PERMIT2_TRANSFER_FROM of 1 WETH into the router, an UNWRAP_WETH + // into the router, then a V4 exact-out paid from the router's ETH. + const UNWRAP_ROUTE = [ + coder.encode( + ["address", "address", "uint160"], + [WETH_ADDR, ADDRESS_THIS, ONE_ETH], + ), + UNWRAP_INTO_ROUTER, + encodeV4Swap( + new Uint8Array([ + V4_SWAP_EXACT_OUT_SINGLE_ID, + V4_SETTLE_ID, + V4_TAKE_ALL_ID, + ]), + [ + encodeV4ExactOutSingle( + ZERO_ADDR, + USDC_ADDR, + 390000000n, + ONE_ETH, + ), + settle(ZERO_ADDR, 0n, false), + encodeV4TakeAll(USDC_ADDR, 0n), + ], + ), + ]; + // The lines of that route on next: this decoder cannot tell what the + // V4 step's SETTLE of ETH pays. + const UNWRAP_ROUTE_LINES = [ + ["Moved from the sender", UNDECODED], + ["Token moved", "ETH"], + ["Moved to", SWAP_STEP], + ]; + test.each([ + { + kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of CONTRACT_BALANCE to another address", + commands: [0x00, 0x0b], + inputs: [V3_600, WRAP_ALL_TO_OTHER], + lines: [ + ["Amount", "600.0000 USDT"], + ...allEthLines(OTHER_ADDR), + ], + }, + { + kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of CONTRACT_BALANCE to the sender", + commands: [0x00, 0x0b], + inputs: [V3_600, WRAP_ALL_TO_SENDER], + lines: [ + ["Amount", "600.0000 USDT"], + ...allEthLines("The sender"), + ], + }, + { + kind: "a V3 exact-out of at most 600 USDT, then a WRAP_ETH of CONTRACT_BALANCE to another address", + commands: [0x01, 0x0b], + inputs: [ + encodeV3SwapExactOut(USER_ADDR, 390000000n, USDT_600, [ + USDC_ADDR, + USDT_ADDR, + ]), + WRAP_ALL_TO_OTHER, + ], + lines: [ + ["Amount", "Up to 600.0000 USDT"], + ...allEthLines(OTHER_ADDR), + ], + }, + { + kind: "a WRAP_ETH of 1 ETH into the router, then a WRAP_ETH of CONTRACT_BALANCE to another address", + commands: [0x0b, 0x0b], + inputs: [WRAP_INTO_ROUTER, WRAP_ALL_TO_OTHER], + lines: [["Amount", "1.0000 ETH"], ...allEthLines(OTHER_ADDR)], + }, + { + kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of CONTRACT_BALANCE into the router, reads as on next", + commands: [0x00, 0x0b], + inputs: [V3_600, encodeWrapEth(ADDRESS_THIS, CONTRACT_BALANCE)], + lines: [["Amount", "600.0000 USDT"]], + }, + { + kind: "a V3 exact-in of 600 USDT, an UNWRAP_WETH to the sender, then a WRAP_ETH of CONTRACT_BALANCE to another address", + commands: [0x00, 0x0c, 0x0b], + inputs: [ + V3_600, + encodeWrapEth(MSG_SENDER, 0n), + WRAP_ALL_TO_OTHER, + ], + lines: [ + ["Amount", "600.0000 USDT"], + ...allEthLines(OTHER_ADDR), + ], + }, + { + kind: "a V4 swap of 600 USDT that takes ETH into the router, then a WRAP_ETH of CONTRACT_BALANCE to the sender, reads as on next", + commands: [0x10, 0x0b], + 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), + ], + ), + WRAP_ALL_TO_SENDER, + ], + lines: [["Amount", "600.0000 USDT"]], + }, + { + kind: "the unwrap route, then a WRAP_ETH of CONTRACT_BALANCE to the sender, reads as on next", + commands: [0x02, 0x0c, 0x10, 0x0b], + inputs: [...UNWRAP_ROUTE, WRAP_ALL_TO_SENDER], + lines: UNWRAP_ROUTE_LINES, + }, + { + kind: "the unwrap route, then a WRAP_ETH of CONTRACT_BALANCE to another address, reads as on next", + commands: [0x02, 0x0c, 0x10, 0x0b], + inputs: [...UNWRAP_ROUTE, WRAP_ALL_TO_OTHER], + lines: UNWRAP_ROUTE_LINES, + }, + ])("$kind", ({ commands, inputs, lines }) => { + const result = decodeSteps(commands, inputs); + expect(linesBetweenTokens(result)).toEqual(lines); + }); + // A V3_SWAP_EXACT_OUT the user pays for, of at most `amountInMax` // USDT for USDC. const v3ExactOut = (amountInMax) =>