diff --git a/README.md b/README.md index 1fd1ff2..1d364ef 100644 --- a/README.md +++ b/README.md @@ -1189,14 +1189,8 @@ number. They can read: `amountOut`, the exact amount it buys. - `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. The line shows the - amount of the step that sets the input side: the last `PERMIT2_PERMIT` step if - there is one, otherwise the first V2 or V3 exact-in, V2 exact-out, `WRAP_ETH` - or V4 swap step that names an input token or amount. A V4 step with a swap - action sets the input side when no earlier step set it, even when it names - neither, as described below. A V2 exact-out step gives its `amountInMax`, and - a V4 swap step the `amountIn` of the swap action that spends its input - currency. + whole open delta", so the calldata states no quantity. Transaction Decoding + below gives the steps whose amounts the line shows. - `Whatever an earlier step sent to the pair (V2 already paid)`: the swap's `Amount` line, when the amount it shows is a V2 exact-in `amountIn` of zero. The router reads that zero as "the pair already holds the input tokens": the @@ -2374,10 +2368,11 @@ view would leave a wallet one click from deletion. - Site origin (bold, scheme and port included) + "wants to send a transaction" - Decoded action (if calldata is recognized): action name, token details, - amounts, what each `PERMIT2_TRANSFER_FROM` moves and where to, steps, - deadline, and unless a swap pays only the From address, a `Paid to` line - for each other address it pays, or saying that a token stays in the router - or who it pays is unknown (see Transaction Decoding) + amounts, what each swap step the user pays for spends of a token other + than `Token In`, what each `PERMIT2_TRANSFER_FROM` moves and where to, + steps, deadline, and unless a swap pays only the From address, a `Paid to` + line for each other address it pays, or saying that a token stays in the + router or who it pays is unknown (see Transaction Decoding) - From: color dot + full address + etherscan link - Contract: color dot + full address + etherscan link, token symbol label if known; for a contract creation, the same sentence as on WaitTx @@ -3127,24 +3122,48 @@ Currently supported: name); and one more reads `Unknown (could not be decoded)` when a step that may pay an address went unread. When no step pays anyone, a single line reads `Unknown (not named in the calldata)`. Wherever there is a `Paid to` line, the - minimum reads `Min. paid out`. After the `Amount` line (after `Token In` when - there is none), 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 - `Token moved into the router` line already give the amount, the token and - where it goes, and so its amount is shown once. These lines change neither the - `Paid to` lines nor the minimum's label. A step goes unread when its recipient - does not decode as an address, or when it is a command the decoder does not - read: every command other than `PERMIT2_PERMIT`, `PERMIT2_TRANSFER_FROM`, - `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, `V3_SWAP_EXACT_IN`, `V4_SWAP`, - `SWEEP`, `TRANSFER`, `PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`, - `ACROSS_V4_DEPOSIT_V3`, `WRAP_ETH`, `UNWRAP_WETH` and `BALANCE_CHECK_ERC20` - (`V3_SWAP_EXACT_OUT`, `PERMIT2_TRANSFER_FROM_BATCH`, `EXECUTE_SUB_PLAN` and - the NFT commands among them), or a type its release names no command for. + minimum reads `Min. paid out`. The `Amount` line gives the amount of the step + that sets the input side: the last `PERMIT2_PERMIT` step if there is one, + otherwise the first V2 or V3 exact-in, V2 exact-out, `WRAP_ETH` or V4 swap + step that names an input token or amount. A V4 step with a swap action sets + the input side when no earlier step set it, even when it names neither (see + Display Consistency). A V2 exact-out step gives its `amountInMax`, and a V4 + swap step the `amountIn` of the swap action that spends its input currency. + Each later swap step that the user pays for also takes its amount, found in + the same way, from the user's wallet: a V2 or V3 exact-in or V2 exact-out step + whose `payerIsUser` is true, or a V4 step with a swap action whose input + currency a `SETTLE` paid by the user (`payerIsUser` true) or a `SETTLE_ALL` + names. When it spends `Token In`, its amount is added to the line's, unless a + `PERMIT2_PERMIT` set the input side, whose amount the line keeps. So 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 either amount is not a quantity (a V4 open delta, + `ALREADY_PAID`, or an amount the decoder did not read), the total is not known + and there is no `Amount` line. A step paid from the router spends what the + router holds and adds nothing, and so does a V2 exact-in whose `amountIn` is + `ALREADY_PAID`, which takes nothing from the user. A step the user pays for + that spends a token other than `Token In` gives three lines after the `Amount` + line (after `Token In` when there is none), in step order: + `Moved from the sender`, its amount, worded as the `Amount` line words one, or + `Unknown (could not be decoded)` when the decoder read none; `Token moved`, + giving the token's address as the `Token In` line does; and `Moved to`, which + reads `The swap step that spends it`. 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 `Token moved into the router` line already give + the amount, the token and where it goes, and so its amount is shown once. + These lines change neither the `Paid to` lines nor the minimum's label. A step + goes unread when its recipient does not decode as an address, or when it is a + command the decoder does not read: every command other than `PERMIT2_PERMIT`, + `PERMIT2_TRANSFER_FROM`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, + `V3_SWAP_EXACT_IN`, `V4_SWAP`, `SWEEP`, `TRANSFER`, `PAY_PORTION`, + `PAY_PORTION_FULL_PRECISION`, `ACROSS_V4_DEPOSIT_V3`, `WRAP_ETH`, + `UNWRAP_WETH` and `BALANCE_CHECK_ERC20` (`V3_SWAP_EXACT_OUT`, + `PERMIT2_TRANSFER_FROM_BATCH`, `EXECUTE_SUB_PLAN` and the NFT commands among + them), or a type its release names no command for. ### Non-Goals Forever diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index ff1173c..e22adb8 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -335,7 +335,8 @@ const UNNAMED_CURRENCY = "Unknown (not named in the calldata)"; // commands this decoder does not read. That action names both of its // currencies, and those commands may name one, so saying the calldata did not // name it would be false. The Paid to line uses it too, when a step may pay an -// address this decoder did not read. +// address this decoder did not read, and so does the amount a swap step the +// user pays for spends of another token, when this decoder read none. const UNDECODED_CURRENCY = "Unknown (could not be decoded)"; // The Paid to line for each token paid to the router, the swap's output or @@ -566,6 +567,7 @@ function decodeV2SwapExactIn(input) { // Decode V2_SWAP_EXACT_OUT (command 0x09) input bytes. // ABI: (address recipient, uint256 amountOut, uint256 amountInMax, // address[] path, bool payerIsUser) +// With `payerIsUser` false the router pays the input from its own balance. function decodeV2SwapExactOut(input) { try { const d = coder.decode( @@ -578,6 +580,7 @@ function decodeV2SwapExactOut(input) { amountInMax: d[2], tokenIn: d[3][0], tokenOut: d[3][d[3].length - 1], + payerIsUser: d[4], }; } catch { return null; @@ -904,7 +907,9 @@ function totalAmount(amounts) { // read. `settledFromRouter` is every SETTLE that the router pays from its own // balance (`payerIsUser` false), as its currency and the amount it pays: a // quantity, CONTRACT_BALANCE (all the router holds), or null when this -// decoder cannot tell (see below). +// decoder cannot tell (see below). `payerIsUser` is true when the user pays +// the step's Token In: a SETTLE paid by the user (`payerIsUser` true) or a +// SETTLE_ALL, which V4Router always has the user pay, names it. function decodeV4Swap(input) { try { const d = coder.decode(["bytes", "bytes[]"], input); @@ -919,6 +924,8 @@ function decodeV4Swap(input) { const routerSettles = []; // The currency of every SETTLE and SETTLE_ALL. const settledCurrencies = []; + // The currency of every SETTLE and SETTLE_ALL the user pays. + const settledByUser = []; // The index of the step's last swap action, read or not, or -1. let lastSwapAction = -1; // Set when an action of the step could not be read. @@ -943,7 +950,9 @@ function decodeV4Swap(input) { ); settleToken = s[0]; settledCurrencies.push(s[0]); - if (!s[2]) { + if (s[2]) { + settledByUser.push(s[0]); + } else { routerSettles.push({ currency: s[0], amount: s[1], @@ -955,6 +964,7 @@ function decodeV4Swap(input) { const s = coder.decode(["address", "uint256"], params[i]); settleToken = s[0]; settledCurrencies.push(s[0]); + settledByUser.push(s[0]); } else if (actionId === V4_TAKE) { // TAKE: (address currency, address recipient, uint256 amount) const t = coder.decode( @@ -1055,6 +1065,7 @@ function decodeV4Swap(input) { recipientUnread, swapUndecoded, hasSwapAction: true, + payerIsUser: settledByUser.includes(settleToken), }; } @@ -1097,6 +1108,7 @@ function decodeV4Swap(input) { recipientUnread, swapUndecoded, hasSwapAction: swaps.length > 0, + payerIsUser: present(tokenIn) && settledByUser.includes(tokenIn), }; } catch { return null; @@ -1125,6 +1137,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { let inputToken = null; let inputAmount = null; let inputEstablished = false; + let inputFromPermit = false; let outputToken = null; let minOutput = null; let outputEstablished = false; @@ -1135,8 +1148,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) { const commandNames = []; // THE INVARIANT: an amount and the token it is counted in always come - // from the same hop. A figure is never rendered against a token that - // did not supply it. + // from the same hop, or from hops that all spend that token. A figure + // is never rendered against a token that did not supply it. // // Both sides are therefore set as a PAIR — never field by field, and // never on truthiness. An address is never falsy once set but an @@ -1152,7 +1165,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // states neither half: the output side always, and the input side // when no earlier hop set it. A half the establishing hop did not // state stays null and the line says so, rather than being filled in - // from a different hop. + // from a different hop. Each later swap step the user pays for adds + // what it spends, in addUserPaid(), the one place the total is formed. const setInput = (token, amount) => { inputToken = present(token) ? token : null; inputAmount = present(amount) ? amount : null; @@ -1170,6 +1184,33 @@ function decode(data, toAddress, chainId, sources, fromAddress) { outputEstablished = true; }; + // What each swap step the user pays for spends of a token other than + // the input side's, as { token, amount }, in step order. + const otherTokensSpent = []; + // A swap step the user pays for (`payerIsUser` true), after the input + // side is set, takes `amount` of `token` from the sender. Of the input + // side's token it adds to the input side's amount, so the Amount line + // is what the steps spend of it together; when either amount is not a + // quantity, the total is not known and the amount is null. Of another + // token it goes in `otherTokensSpent`. A V2 exact-in of ALREADY_PAID + // takes nothing from the sender, and a permit's amount stays as the + // permit states it. + const addUserPaid = (token, amount) => { + if (amount === ALREADY_PAID) return; + const sameToken = + present(inputToken) && + token.toLowerCase() === inputToken.toLowerCase(); + if (!sameToken) { + otherTokensSpent.push({ token, amount }); + return; + } + if (inputFromPermit) return; + inputAmount = + typeof inputAmount === "bigint" && typeof amount === "bigint" + ? inputAmount + amount + : null; + }; + // Who the steps pay, in whichever step and of whatever token. // MSG_SENDER is the sender. The router itself, as ADDRESS_THIS or by // its own address, holds what it is paid until a later step pays it @@ -1309,7 +1350,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // A permit states both halves itself, so it may replace an // input side an earlier hop established without breaking // the invariant. - if (p) setInput(p.token, p.amount); + if (p) { + setInput(p.token, p.amount); + inputFromPermit = true; + } } else if (command === "PERMIT2_TRANSFER_FROM") { const p = decodePermit2TransferFrom(inputs[i]); if (!p) { @@ -1375,7 +1419,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } else if (command === "V3_SWAP_EXACT_IN") { const s = decodeV3SwapExactIn(inputs[i]); if (s) { - setInputOnce(s.tokenIn, s.amountIn); + if (!inputEstablished) { + setInputOnce(s.tokenIn, s.amountIn); + } else if (s.payerIsUser) { + addUserPaid(s.tokenIn, s.amountIn); + } // Always update output: in multi-step swaps (V3 → V4), // the last swap step determines the final output token // and minimum received amount. @@ -1392,7 +1440,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } else if (command === "V2_SWAP_EXACT_IN") { const s = decodeV2SwapExactIn(inputs[i]); if (s) { - setInputOnce(s.tokenIn, s.amountIn); + if (!inputEstablished) { + setInputOnce(s.tokenIn, s.amountIn); + } else if (s.payerIsUser) { + addUserPaid(s.tokenIn, s.amountIn); + } setOutput(s.tokenOut, s.amountOutMin); if (!s.payerIsUser) { spendFromRouter(s.tokenIn, s.amountIn); @@ -1408,7 +1460,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) { hasV2ExactOut = true; const s = decodeV2SwapExactOut(inputs[i]); if (s) { - setInputOnce(s.tokenIn, s.amountInMax); + if (!inputEstablished) { + setInputOnce(s.tokenIn, s.amountInMax); + } else if (s.payerIsUser) { + addUserPaid(s.tokenIn, s.amountInMax); + } setOutput(s.tokenOut, s.amountOut); if (!isHop(i, s.tokenOut, s.recipient)) { addPayee(s.recipient, s.tokenOut); @@ -1550,6 +1606,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // other half. if (!inputEstablished) { setInput(v4.tokenIn, v4.amountIn); + } else if (v4.payerIsUser) { + addUserPaid(v4.tokenIn, v4.amountIn); } outputToken = v4.tokenOut; minOutput = v4.amountOutMin; @@ -1672,37 +1730,43 @@ function decode(data, toAddress, chainId, sources, fromAddress) { }); } - if (present(inputAmount)) { - // 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. - let amount; - if (inputAmount === OPEN_DELTA) { - amount = { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT }; - } else if (inputAmount === ALREADY_PAID) { - amount = { + // 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. + const spentText = (amount, info) => { + if (amount === OPEN_DELTA) { + return { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT }; + } + if (amount === ALREADY_PAID) { + return { raw: ALREADY_PAID_AMOUNT, display: ALREADY_PAID_AMOUNT, }; - } else if (inputAmount >= MAX_UINT160) { - amount = { raw: "Unlimited", display: "Unlimited" }; - } else if (hasV2ExactOut) { + } + if (amount >= MAX_UINT160) { + return { raw: "Unlimited", display: "Unlimited" }; + } + if (hasV2ExactOut) { // A V2 exact-out swap spends at most this figure, whichever - // step set the line (its amountInMax, the WRAP_ETH of a swap - // paid in ETH, a permit), so it is said to be a maximum, in - // `raw` too: the wait, success and error screens show `raw` as - // the transaction's amount. - const most = amountText(inputAmount, inInfo); - amount = { + // steps gave it (an amountInMax, the WRAP_ETH of a swap paid + // in ETH, a permit, or several steps' total), so it is said to + // be a maximum, in `raw` too: the wait, success and error + // screens show `raw` as the transaction's amount. + const most = amountText(amount, info); + return { raw: "Up to " + most.raw, display: "Up to " + most.display, }; - } else { - amount = amountText(inputAmount, inInfo); } + return amountText(amount, info); + }; + + if (present(inputAmount)) { + const amount = spentText(inputAmount, inInfo); details.push({ label: "Amount", value: amount.display, @@ -1710,6 +1774,23 @@ function decode(data, toAddress, chainId, sources, fromAddress) { }); } + // What each swap step the user pays for spends of another token, in + // the shape of the lines below for a PERMIT2_TRANSFER_FROM. An amount + // this decoder did not read is said so. + for (const s of otherTokensSpent) { + details.push({ + label: "Moved from the sender", + value: present(s.amount) + ? spentText(s.amount, tokenInfo(s.token, sources)).display + : UNDECODED_CURRENCY, + }); + details.push(tokenMovedLine("Token moved", s.token, sources)); + details.push({ + label: "Moved to", + value: "The swap step that spends it", + }); + } + // What each PERMIT2_TRANSFER_FROM moves: its amount, its token and // where it goes, an address on a line of its own. for (const m of movedFromSender) { diff --git a/tests/uniswap.test.js b/tests/uniswap.test.js index de5f74a..9d7775f 100644 --- a/tests/uniswap.test.js +++ b/tests/uniswap.test.js @@ -1289,6 +1289,194 @@ describe("uniswap decoder", () => { expect(detail(result, "Amount").value).not.toContain("500000000000"); }); + // https://git.eeqj.de/sneak/AutistMask/issues/625 — a route split across + // two swap steps the user pays for, with no PERMIT2_PERMIT, takes both + // steps' amounts from the wallet. On next at 671b4d3 the Amount line read + // only the first step's 600.0000 USDT. + describe("a swap split across steps the user pays for", () => { + const USDT_600 = 600000000n; + const USDT_400 = 400000000n; + + // The lines from Amount on, as [label, value]. + function linesFromAmount(result, count) { + const lines = result.details.map((d) => [d.label, d.value]); + const at = lines.findIndex(([label]) => label === "Amount"); + return lines.slice(at, at + count); + } + + test("a split across a V3 and a V2 step the user pays for shows the total", () => { + const data = buildExecute( + solidityPacked(["uint8", "uint8"], [0x00, 0x08]), + [ + encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [ + USDT_ADDR, + USDC_ADDR, + ]), + encodeV2SwapExactIn(USER_ADDR, USDT_400, 390000000n, [ + USDT_ADDR, + USDC_ADDR, + ]), + ], + 9999999999n, + ); + + const result = uniswap.decode( + data, + ROUTER_ADDR, + "0x1", + {}, + USER_ADDR, + ); + expect(result).not.toBeNull(); + + expect(detail(result, "Token In").address.toLowerCase()).toBe( + USDT_ADDR.toLowerCase(), + ); + expect(detail(result, "Amount").value).toBe("1000.0000 USDT"); + expect(detail(result, "Amount").rawValue).toBe("1000.0000"); + expect(detail(result, "Moved from the sender")).toBeUndefined(); + }); + + test("a split across a V3 step and a V4 step the user pays for shows the total", () => { + const data = buildExecute( + solidityPacked(["uint8", "uint8"], [0x00, 0x10]), + [ + encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [ + USDT_ADDR, + USDC_ADDR, + ]), + encodeV4Swap( + new Uint8Array([ + V4_SETTLE_ID, + V4_SWAP_EXACT_IN_SINGLE_ID, + V4_TAKE_ID, + ]), + [ + encodeV4Settle(USDT_ADDR), // paid by the user + encodeV4ExactInSingle( + USDT_ADDR, + USDC_ADDR, + USDT_400, + 390000000n, + ), + encodeV4Take(USDC_ADDR), + ], + ), + ], + 9999999999n, + ); + + const result = uniswap.decode( + data, + ROUTER_ADDR, + "0x1", + {}, + USER_ADDR, + ); + expect(result).not.toBeNull(); + + expect(detail(result, "Amount").value).toBe("1000.0000 USDT"); + expect(detail(result, "Moved from the sender")).toBeUndefined(); + }); + + test("a second step the user pays for that spends another token shows that token's amount", () => { + const data = buildExecute( + solidityPacked(["uint8", "uint8"], [0x00, 0x08]), + [ + encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [ + USDT_ADDR, + USDC_ADDR, + ]), + encodeV2SwapExactIn( + USER_ADDR, + 400000000000000000000n, // 400 DAI + 390000000n, + [DAI_ADDR, USDC_ADDR], + ), + ], + 9999999999n, + ); + + const result = uniswap.decode( + data, + ROUTER_ADDR, + "0x1", + {}, + USER_ADDR, + ); + expect(result).not.toBeNull(); + + expect(linesFromAmount(result, 4)).toEqual([ + ["Amount", "600.0000 USDT"], + ["Moved from the sender", "400.0000 DAI"], + ["Token moved", "DAI (" + DAI_ADDR + ")"], + ["Moved to", "The swap step that spends it"], + ]); + }); + + // A PERMIT2_PERMIT that sets the input side keeps its own amount: the + // steps it lets the router spend do not add to it. + test("a split after a PERMIT2_PERMIT shows the permit's amount", () => { + const data = buildExecute( + solidityPacked(["uint8", "uint8", "uint8"], [0x0a, 0x00, 0x08]), + [ + encodePermit2(USDT_ADDR, 5000000000n, ROUTER_ADDR), // 5000 USDT + encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [ + USDT_ADDR, + USDC_ADDR, + ]), + encodeV2SwapExactIn(USER_ADDR, USDT_400, 390000000n, [ + USDT_ADDR, + USDC_ADDR, + ]), + ], + 9999999999n, + ); + + const result = uniswap.decode( + data, + ROUTER_ADDR, + "0x1", + {}, + USER_ADDR, + ); + expect(result).not.toBeNull(); + + expect(detail(result, "Amount").value).toBe("5000.0000 USDT"); + expect(detail(result, "Moved from the sender")).toBeUndefined(); + }); + + // A V2 exact-out step makes every amount the swap steps spend a maximum, + // the total included. + test("a split with a V2 exact-out step the user pays for shows the total as a maximum", () => { + const data = buildExecute( + solidityPacked(["uint8", "uint8"], [0x00, 0x09]), + [ + encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [ + USDT_ADDR, + USDC_ADDR, + ]), + encodeV2SwapExactOut(USER_ADDR, 390000000n, USDT_400, [ + USDT_ADDR, + USDC_ADDR, + ]), + ], + 9999999999n, + ); + + const result = uniswap.decode( + data, + ROUTER_ADDR, + "0x1", + {}, + USER_ADDR, + ); + expect(result).not.toBeNull(); + + expect(detail(result, "Amount").value).toBe("Up to 1000.0000 USDT"); + }); + }); + // https://git.eeqj.de/sneak/AutistMask/issues/359 — the output half, in // the shape the issue measured: a V4 step that states a minimum of zero // and names no output currency. diff --git a/tests/uniswapRecipient.test.js b/tests/uniswapRecipient.test.js index 364f348..8ebb6ac 100644 --- a/tests/uniswapRecipient.test.js +++ b/tests/uniswapRecipient.test.js @@ -671,6 +671,7 @@ describe("any token left in the router is not received", () => { v3SwapExactIn(MSG_SENDER, USDT_2000, USDC_1990, USDT, USDC), ), left: tokenLine(WETH, "WETH"), + spent: "2000.0000 USDT", }, { kind: "a WRAP_ETH of 1 ETH into the router, then a swap of 0.5 WETH from the router", @@ -697,6 +698,7 @@ describe("any token left in the router is not received", () => { v2Swap(0x08, MSG_SENDER, ONE_WETH, USDC_1990, [WETH, USDC]), ), left: tokenLine(WETH, "WETH"), + spent: "1.0000 WETH", }, { kind: "a WRAP_ETH of 1 ETH into the router, then a V3 swap of 1 WETH that the user pays for", @@ -705,6 +707,7 @@ describe("any token left in the router is not received", () => { v3SwapExactIn(MSG_SENDER, ONE_WETH, USDC_1990, WETH, USDC), ), left: tokenLine(WETH, "WETH"), + spent: "1.0000 WETH", }, { kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of 1 WETH with a SETTLE of 1 WETH by the user", @@ -717,6 +720,7 @@ describe("any token left in the router is not received", () => { ), ), left: tokenLine(WETH, "WETH"), + spent: "1.0000 WETH", }, { kind: "a swap paying WETH to the router, a WRAP_ETH of 1 ETH into the router, then a swap of 1 WETH from the router", @@ -792,6 +796,7 @@ describe("any token left in the router is not received", () => { ), ), left: tokenLine(WETH, "WETH"), + spent: "1.0000 WETH", }, // The user pays for the swap by a SETTLE_ALL or SETTLE whose currency // the decoder cannot read, so the SETTLE from the router pays nothing. @@ -873,12 +878,17 @@ describe("any token left in the router is not received", () => { ), left: { label: "Token moved into the router", value: "ETH" }, }, - ])("$kind", ({ data, left }) => { + ])("$kind", ({ data, left, spent }) => { const result = decode(data); expect(labels(result)).toEqual([ "Protocol", "Token In", "Amount", + // A later swap step the user pays for spends `spent` of a token + // other than Token In (https://git.eeqj.de/sneak/AutistMask/issues/625). + ...(spent + ? ["Moved from the sender", "Token moved", "Moved to"] + : []), "Token Out", "Paid to", "Token moved into the router", @@ -886,6 +896,9 @@ describe("any token left in the router is not received", () => { "Steps", "Deadline", ]); + if (spent) { + expect(detail(result, "Moved from the sender").value).toBe(spent); + } expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]); expect(detail(result, "Token moved into the router")).toEqual(left); expect(detail(result, "Min. paid out").value).toBe("1990.0000 USDC");