diff --git a/README.md b/README.md index dfd0dd6..e1e397a 100644 --- a/README.md +++ b/README.md @@ -1416,8 +1416,10 @@ when both tokens are known. The lines are: 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 + and gets these lines if not. A later `WRAP_ETH` of `CONTRACT_BALANCE` to + anyone but the router gets these lines, unless an earlier step paid ETH to the + router. 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. diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index 21c4935..7fd53f1 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". @@ -1271,6 +1277,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // 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 index of the WRAP_ETH that set the input side, or null. + let wrapEthStep = 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; @@ -1289,6 +1297,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 @@ -1308,6 +1319,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // other than WETH, and not of ETH to a WETH contract, which pays the // router WETH (see keepsRouterWeth in the loop). None of those can. let lastWethStep = -1; + // The index of the last swap step paid from WETH the router holds, a + // V4 SETTLE included, or -1 (see spendFromRouter()). Only such a step + // spends the WETH of an earlier WRAP_ETH into the router. + let lastRouterWethSwapStep = -1; // Where the step that set the output side paid its output: the // recipient of a swap step, as written, or of each TAKE, TAKE_ALL and // TAKE_PORTION of it in a V4 step, a TAKE_ALL as paid to the sender, @@ -1370,7 +1385,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // `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. + // Amount line either: its Moved to line names that recipient. The + // take of a later WRAP_ETH of a stated amount has that step's index as + // `wrapStep`. 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 @@ -1445,17 +1462,20 @@ function decode(data, toAddress, chainId, sources, fromAddress) { const r = recipient.toLowerCase(); return r === MSG_SENDER || r === sender; }; - // A swap step or V4 SETTLE pays `amount` of `token` from the router's - // own balance. CONTRACT_BALANCE pays all of it. Any other quantity - // counts against the amount a WRAP_ETH stated, and the steps pay all - // of it once their amounts reach that one: a swap paid in ETH wraps - // what its swap steps then spend. An amount that is not a quantity, - // such as the null decodeV4Swap() gives a SETTLE whose payment it - // cannot tell, or one of a token whose amount is not known, pays out - // nothing this decoder can see. - const spendFromRouter = (token, amount) => { + // A swap step or V4 SETTLE at `step` pays `amount` of `token` from the + // router's own balance. CONTRACT_BALANCE pays all of it. Any other + // quantity counts against the amount a WRAP_ETH stated, and the steps + // pay all of it once their amounts reach that one: a swap paid in ETH + // wraps what its swap steps then spend. An amount that is not a + // quantity, such as the null decodeV4Swap() gives a SETTLE whose + // payment it cannot tell, or one of a token whose amount is not known, + // pays out nothing this decoder can see. + const spendFromRouter = (step, token, amount) => { const t = token.toLowerCase(); const held = heldByRouter.get(t); + if (t === WETH.get(chainId) && heldByRouter.has(t)) { + lastRouterWethSwapStep = step; + } if (amount === CONTRACT_BALANCE) { heldByRouter.delete(t); } else if (typeof held === "bigint" && typeof amount === "bigint") { @@ -1741,7 +1761,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { [s.recipient], ); if (!s.payerIsUser) { - spendFromRouter(s.tokenIn, s.amountIn); + spendFromRouter(i, s.tokenIn, s.amountIn); } if (!isHop(i, s.tokenOut, s.recipient)) { addPayee(s.recipient, s.tokenOut); @@ -1770,7 +1790,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { [s.recipient], ); if (!s.payerIsUser) { - spendFromRouter(s.tokenIn, s.amountIn); + spendFromRouter(i, s.tokenIn, s.amountIn); } if (!isHop(i, s.tokenOut, s.recipient)) { addPayee(s.recipient, s.tokenOut); @@ -1786,8 +1806,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { ) { // Buys exactly amountOut and spends at most amountInMax. // Paid from the router, it spends an amount the calldata - // does not state, so it spends nothing spendFromRouter() - // could count. + // does not state, so spendFromRouter() has none to count. hasV2OrV3ExactOut = true; const s = command === "V2_SWAP_EXACT_OUT" @@ -1809,6 +1828,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) { ]), [s.recipient], ); + if (!s.payerIsUser) { + spendFromRouter(i, s.tokenIn, null); + } if (!isHop(i, s.tokenOut, s.recipient)) { addPayee(s.recipient, s.tokenOut); } @@ -1906,6 +1928,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { w.amount, ); inputPaidByUser = true; + wrapEthStep = i; if (!isRouter(w.recipient)) { wrapEthRecipient = w.recipient; } @@ -1916,8 +1939,7 @@ 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, @@ -1927,6 +1949,25 @@ function decode(data, toAddress, chainId, sources, fromAddress) { recipient: isRouter(w.recipient) ? null : w.recipient, + wrapStep: i, + }); + } else if ( + w.amount === CONTRACT_BALANCE && + !isRouter(w.recipient) && + !unwrappedIntoRouter && + !heldByRouter.has( + "0x0000000000000000000000000000000000000000", + ) + ) { + // It pays the recipient all the ETH the transaction + // carries. After an earlier step paid ETH to the + // router there is no line: 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); @@ -1996,7 +2037,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // Each SETTLE paid from the router spends the amount // decodeV4Swap() says it pays. for (const s of v4.settledFromRouter) { - spendFromRouter(s.currency, s.amount); + spendFromRouter(i, s.currency, s.amount); } for (const t of v4.takes) { if (!isHop(i, t.currency, t.recipient)) { @@ -2169,6 +2210,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", @@ -2264,10 +2308,11 @@ 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, 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). + // { token, amount, atMost, recipient, wrapStep }. 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). Only the take of a WRAP_ETH has a `wrapStep`. const spentFromSender = []; let total = null; // The takes from the sender that `total` adds up, each a quantity, in @@ -2292,6 +2337,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { token: inputToken, amount: inputAmount, recipient: wrapEthRecipient, + wrapStep: wrapEthStep, }); } if (hasFirstTake && !present(inputAmount)) { @@ -2304,6 +2350,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { token: inputToken, amount: inputAmount, recipient: wrapEthRecipient, + wrapStep: wrapEthStep, }); } if (permitStands) inputToken = permit.token; @@ -2413,13 +2460,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 +2478,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 +2543,9 @@ 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. The take of a WRAP_ETH into the router goes to the + // router, unless a later swap step spends its WETH. 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", @@ -2512,7 +2567,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) { if (!present(s.recipient)) { details.push({ label: "Moved to", - value: "The swap step that spends it", + value: + present(s.wrapStep) && + s.wrapStep > lastRouterWethSwapStep + ? "The router" + : "The swap step that spends it", }); } else if (s.recipient.toLowerCase() === MSG_SENDER) { details.push({ label: "Moved to", value: "The sender" }); diff --git a/tests/uniswap.test.js b/tests/uniswap.test.js index 76fde00..61c652e 100644 --- a/tests/uniswap.test.js +++ b/tests/uniswap.test.js @@ -2474,10 +2474,9 @@ 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. 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 +2624,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 +2673,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 +2696,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 +2757,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 +2783,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 +2792,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 +2835,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 +2875,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 +2915,327 @@ describe("uniswap decoder", () => { expect(linesBetweenTokens(result)).toEqual(lines); }); + // https://git.eeqj.de/sneak/AutistMask/issues/747 — the WETH of a + // WRAP_ETH into the router goes to a swap step only when a later swap + // step paid from the router spends it. On next every row not marked + // "reads as on next" read The swap step that spends it. + 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(SWAP_STEP), + ...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, reads as on next", + commands: [0x00, 0x0b, 0x00], + inputs: [V3_600, WRAP_INTO_ROUTER, V3_WETH_FROM_ROUTER], + lines: [["Amount", "600.0000 USDT"], ...wrapLines(SWAP_STEP)], + }, + { + kind: "the same with a V2 exact-in paid from the router's WETH, reads as on next", + commands: [0x00, 0x0b, 0x08], + inputs: [V3_600, WRAP_INTO_ROUTER, V2_WETH_FROM_ROUTER], + lines: [["Amount", "600.0000 USDT"], ...wrapLines(SWAP_STEP)], + }, + { + kind: "the same with a V3 exact-out paid from the router's WETH, reads as on next", + 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", SWAP_STEP], + ], + }, + { + kind: "the same with a V4 swap that the router's WETH settles, reads as on next", + commands: [0x00, 0x0b, 0x10], + inputs: [V3_600, WRAP_INTO_ROUTER, V4_WETH_FROM_ROUTER], + lines: [["Amount", "600.0000 USDT"], ...wrapLines(SWAP_STEP)], + }, + { + 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, reads as on next", + commands: [0x0b, 0x00, 0x21], + inputs: [WRAP_INTO_ROUTER, V3_WETH_FROM_ROUTER, SUB_PLAN], + lines: [ + ...wrapLines(SWAP_STEP), + ["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, reads as on next", + 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(SWAP_STEP)], + }, + ])("$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. Uniswap's own routes pay back what + // their swap did not spend this way, and carry no ETH, so 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, 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) =>