diff --git a/README.md b/README.md index d5a5221..8b29b01 100644 --- a/README.md +++ b/README.md @@ -3414,18 +3414,23 @@ Currently supported: `WRAP_ETH` ran: after two `WRAP_ETH`s of 0.5 ETH into the router, a swap of 1 WETH from it reads as leaving the WETH there. A V2 or V3 exact-out step paid from the router spends an amount the calldata does not state, at most its - `amountInMax`, so the decoder counts it as spending nothing the router holds. - A `SETTLE` of `OPEN_DELTA` (zero), and a `SETTLE_ALL`, pay only what the step - still owes of their token when they run. The decoder takes that to be the - amounts the step's swap actions state they spend of that token only when every - action in the step could be read, every swap action names the token it spends - and the token it buys (a swap action with an empty path swaps nothing), the - `SETTLE` or `SETTLE_ALL` is the step's only `SETTLE` or `SETTLE_ALL` of that - token and comes after every swap action, no swap action buys that token, no - `TAKE`, `TAKE_ALL` or `TAKE_PORTION` in the step takes it (a `TAKE` of a - stated amount adds to what the step owes), and the swap actions that spend it - state a quantity together; otherwise it does not know the amount, and such a - `SETTLE` from the router counts as spending nothing. So the rest of a V4 + `amountInMax`. The decoder then no longer knows how much of the step's input + token the router holds. A V4 `SETTLE` from the router has the same effect when + the decoder does not know what that `SETTLE` pays (see below). An + `UNWRAP_WETH` into the router turns all the router's WETH into the same amount + of ETH. The decoder knows how much ETH that is only when it knew how much WETH + the router held and the router held no ETH before. A V4 `SETTLE` of ETH always + spends the router's own ETH, whichever payer it names (see below). A `SETTLE` + of `OPEN_DELTA` (zero), and a `SETTLE_ALL`, pay only what the step still owes + of their token when they run. The decoder takes that to be the amounts the + step's swap actions state they spend of that token only when every action in + the step could be read, every swap action names the token it spends and the + token it buys (a swap action with an empty path swaps nothing), the `SETTLE` + or `SETTLE_ALL` is the step's only `SETTLE` or `SETTLE_ALL` of that token and + comes after every swap action, no swap action buys that token, no `TAKE`, + `TAKE_ALL` or `TAKE_PORTION` in the step takes it (a `TAKE` of a stated amount + adds to what the step owes), and the swap actions that spend it state a + quantity together; otherwise it does not know the amount. So the rest of a V4 `SETTLE` of more than its swap spends, taken to the router, the WETH of a `WRAP_ETH`, and a token a swap pays the router on the way to the output, each stays in the router when later steps do not spend all of it, where anyone can @@ -3477,70 +3482,79 @@ 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 ETH from the + router's own ETH, whoever a `SETTLE` names as payer. So when a V4 step would + take ETH from the user's wallet, ETH the router already holds pays first. The + wallet pays only the rest. For example, an `UNWRAP_WETH` into the router + leaves ETH there. The decoder knows what the wallet pays only when it knows + how much ETH the router holds and how much the step would take. Otherwise that + payment reads as unknown, like any amount the decoder does not know. 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 `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 + `Moved to`, which reads `The swap step that spends it`. 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; diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index 2a00fff..ee295b5 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; @@ -1399,9 +1403,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // token, an UNWRAP_WETH of WETH, a WRAP_ETH of CONTRACT_BALANCE of // ETH, a TRANSFER, PAY_PORTION, PAY_PORTION_FULL_PRECISION or // ACROSS_V4_DEPOSIT_V3 of all of it, or a swap step or V4 SETTLE paid - // from the router (see spendFromRouter()). The value is the amount the - // router holds, in base units, when a WRAP_ETH stated it, otherwise - // null. + // from the router (see spendFromRouter()). A V4 SETTLE of ETH paid by + // the user spends the router's ETH too (see the V4_SWAP branch). The + // value is the amount the router holds, in base units, when a + // WRAP_ETH stated it, otherwise null. A step that spends an unstated + // amount of the token from the router makes the value null. An + // UNWRAP_WETH into the router turns that amount of WETH into the same + // amount of ETH. const heldByRouter = new Map(); const isRouter = (recipient) => { const r = recipient.toLowerCase(); @@ -1409,17 +1417,19 @@ function decode(data, toAddress, chainId, sources, fromAddress) { }; // 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. + // counts against a known amount the router holds, 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. A null amount is one the calldata + // does not state, as for a V2 or V3 exact-out or a SETTLE whose + // payment decodeV4Swap() cannot tell. A null amount makes the + // router's amount of the token unknown. ALREADY_PAID spends nothing. const spendFromRouter = (token, amount) => { const t = token.toLowerCase(); const held = heldByRouter.get(t); if (amount === CONTRACT_BALANCE) { heldByRouter.delete(t); + } else if (amount === null) { + if (heldByRouter.has(t)) heldByRouter.set(t, null); } else if (typeof held === "bigint" && typeof amount === "bigint") { if (amount >= held) { heldByRouter.delete(t); @@ -1675,8 +1685,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 (see spendFromRouter()). hasV2OrV3ExactOut = true; const s = command === "V2_SWAP_EXACT_OUT" @@ -1690,6 +1699,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) { addUserPaid(s.tokenIn, s.amountInMax); } setOutput(i, s.tokenOut, s.amountOut, [s.recipient]); + if (!s.payerIsUser) { + spendFromRouter(s.tokenIn, null); + } if (!isHop(i, s.tokenOut, s.recipient)) { addPayee(s.recipient, s.tokenOut); } @@ -1787,6 +1799,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,12 +1830,49 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } else if (command === "V4_SWAP") { const v4 = decodeV4Swap(inputs[i]); const inputSetBefore = inputEstablished; + // What the user pays the step, as decodeV4Swap() gives + // it, less the ETH the router already holds (below). + // Empty when the step cannot be read. + const paidByUser = []; if (v4) { // Each SETTLE paid from the router spends the amount // decodeV4Swap() says it pays. for (const s of v4.settledFromRouter) { spendFromRouter(s.currency, s.amount); } + // V4Router pays ETH from the router's own ETH, + // whoever a SETTLE names as payer. So ETH the router + // holds from an earlier step pays first for the ETH + // the user would pay the step. The user pays only the + // rest. This decoder knows the rest only when it knows + // how much ETH the router holds and how much the user + // would pay. Otherwise the rest is unknown. + for (const p of v4.paidByUser) { + if ( + p.currency !== + "0x0000000000000000000000000000000000000000" || + !heldByRouter.has(p.currency) + ) { + paidByUser.push(p); + continue; + } + const held = heldByRouter.get(p.currency); + if ( + typeof held !== "bigint" || + typeof p.amount !== "bigint" + ) { + paidByUser.push({ + currency: p.currency, + amount: null, + }); + } else if (p.amount > held) { + paidByUser.push({ + currency: p.currency, + amount: p.amount - held, + }); + } + spendFromRouter(p.currency, p.amount); + } for (const t of v4.takes) { if (!isHop(i, t.currency, t.recipient)) { addPayee(t.recipient, t.currency); @@ -1845,10 +1897,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) { commandNames[i] += " (could not be decoded)"; } // The step's amount, if it sets the input side: what the - // user pays it of its input currency (see - // decodeV4Swap()), or, when the user pays none of it, - // what its swap actions spend of it, whoever pays that. - const paidIn = v4?.paidByUser.find( + // user pays it of its input currency (see paidByUser), + // or, when the user pays none of it, what its swap + // actions spend of it, whoever pays that. + const paidIn = paidByUser.find( (p) => p.currency === v4.tokenIn, ); const amountIn = paidIn ? paidIn.amount : v4?.amountIn; @@ -1902,11 +1954,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // router still runs it. const setInputHere = inputEstablished && !inputSetBefore; if (setInputHere) inputPaidByUser = paidIn !== undefined; - if (v4) { - for (const p of v4.paidByUser) { - if (setInputHere && p === paidIn) continue; - addUserPaid(p.currency, p.amount); - } + for (const p of paidByUser) { + if (setInputHere && p === paidIn) continue; + addUserPaid(p.currency, p.amount); } if (!v4 || v4.settleUnread) { spentUnread(); @@ -1954,15 +2004,23 @@ function decode(data, toAddress, chainId, sources, fromAddress) { addPayee(u.recipient); // It pays out all the router's WETH, as ETH. Into the // router, the router then holds as ETH the WETH a - // step paid it. - const heldWeth = WETH_ADDRESSES.some((w) => + // step paid it. That amount is known when the router + // held a known amount of WETH and no ETH before. + const heldWeth = WETH_ADDRESSES.filter((w) => heldByRouter.has(w), ); - WETH_ADDRESSES.forEach((w) => heldByRouter.delete(w)); - if (isRouter(u.recipient) && heldWeth) { + const ethAmount = + heldWeth.length === 1 && + !heldByRouter.has( + "0x0000000000000000000000000000000000000000", + ) + ? heldByRouter.get(heldWeth[0]) + : null; + heldWeth.forEach((w) => heldByRouter.delete(w)); + if (isRouter(u.recipient) && heldWeth.length > 0) { heldByRouter.set( "0x0000000000000000000000000000000000000000", - null, + ethAmount, ); } } else { @@ -2051,14 +2109,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, recipient }. A null token is a step this decoder + // did not read (see spentUnread()), or a token it does not know. 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 +2134,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 +2146,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) { @@ -2260,7 +2329,9 @@ 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", @@ -2270,10 +2341,20 @@ function decode(data, toAddress, chainId, sources, fromAddress) { }); 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..6a8639b 100644 --- a/tests/uniswap.test.js +++ b/tests/uniswap.test.js @@ -2424,6 +2424,250 @@ 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 ETH + // from the router's own ETH, whoever a SETTLE names as payer. ETH the + // router holds from an UNWRAP_WETH into it pays first, so the sender + // pays only the ETH beyond it. On next each row also took the V4 + // step's whole ETH from the sender: 1 ETH more in the first, and 1.5 + // ETH in the last. + const unwrapIntoRouter = encodeWrapEth(ROUTER_ADDR, 0n); // UNWRAP_WETH, same encoding + // 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), + ], + ); + test.each([ + { + 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: [ + encodeWrapEth(ROUTER_ADDR, ONE_ETH), + unwrapIntoRouter, + v4EthForUsdc(ONE_ETH), + ], + lines: [["Amount", "1.0000 ETH"]], + }, + { + kind: "a WRAP_ETH of 1 ETH into the router, an UNWRAP_WETH into the router, then a V4 swap of 1.5 ETH that the user settles", + commands: [0x0b, 0x0c, 0x10], + inputs: [ + encodeWrapEth(ROUTER_ADDR, ONE_ETH), + unwrapIntoRouter, + v4EthForUsdc((ONE_ETH * 3n) / 2n), + ], + lines: [["Amount", "1.5000 ETH"]], + }, + ])("$kind", ({ commands, inputs, lines }) => { + const result = decodeSteps(commands, inputs); + expect(linesBetweenTokens(result)).toEqual(lines); + }); + + // The lines for ETH the sender pays a V4 step, of an amount this + // decoder does not know. + const unknownEthLines = [ + ["Moved from the sender", UNDECODED], + ["Token moved", "ETH"], + ["Moved to", "The swap step that spends it"], + ]; + + // https://git.eeqj.de/sneak/AutistMask/issues/665 — this decoder may + // not know how much ETH the router holds, or how much ETH the user + // pays a V4 step. The ETH the sender pays that step then reads as + // unknown. Counting the router as paying all of it would hide ETH + // that can leave the wallet. + test.each([ + { + 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(ROUTER_ADDR, USDT_600, 1n, [ + USDT_ADDR, + WETH_ADDR, + ]), + unwrapIntoRouter, + v4EthForUsdc(ONE_ETH), + ], + lines: [["Amount", "600.0000 USDT"], ...unknownEthLines], + }, + { + 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, ROUTER_ADDR, 0n), + ], + ), + v4EthForUsdc(5n * ONE_ETH), + ], + lines: [["Amount", "600.0000 USDT"], ...unknownEthLines], + }, + { + 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: [ + encodeWrapEth(ROUTER_ADDR, ONE_ETH), + unwrapIntoRouter, + 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"), + ...unknownEthLines, + ], + }, + ])("$kind", ({ commands, inputs, lines }) => { + const result = decodeSteps(commands, inputs); + expect(linesBetweenTokens(result)).toEqual(lines); + }); + + // https://git.eeqj.de/sneak/AutistMask/issues/665 — a step that spends + // an unstated amount of a token from the router makes the router's + // amount of that token unknown. In each row such a step spends the + // WETH of a WRAP_ETH of 1 ETH into the router. The UNWRAP_WETH then + // leaves an unknown amount of ETH there, so the ETH the sender pays + // the last step reads as unknown. + test.each([ + { + 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: [ + encodeWrapEth(ROUTER_ADDR, ONE_ETH), + encodeV3SwapExactOut( + USER_ADDR, + 390000000n, + ONE_ETH, + [USDC_ADDR, WETH_ADDR], + false, + ), + unwrapIntoRouter, + v4EthForUsdc(ONE_ETH), + ], + lines: [ + ["Moved from the sender", "Up to 1.0000 ETH"], + ["Token moved", "ETH"], + ["Moved to", "The swap step that spends it"], + ...unknownEthLines, + ], + }, + { + 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: [ + encodeWrapEth(ROUTER_ADDR, ONE_ETH), + 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), + ], + ), + unwrapIntoRouter, + v4EthForUsdc(ONE_ETH), + ], + lines: [ + ...wrapLines("The swap step that spends it"), + ...unknownEthLines, + ], + }, + ])("$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) =>