diff --git a/README.md b/README.md index 445096e..9fe850f 100644 --- a/README.md +++ b/README.md @@ -1144,8 +1144,9 @@ fetched, without that cross-address check, and shows the row's figure. **Decoded amount lines on the transaction approval screen:** the `Amount` line of a decoded ERC-20 call, and the `Amount` and `Min. received` lines of a -decoded swap (see TxApproval below), do not always read as a number. They can -read: +decoded swap (see TxApproval below; `Min. received` reads `Min. paid out` unless +only the user is paid, see Transaction Decoding), do not always read as a +number. They can read: - A formatted quantity, e.g. `17.1900 USDT`, when the token's scale is known: truncated to four decimals, with the floor and the zero cases described above. @@ -2288,7 +2289,9 @@ 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, steps, deadline (see Transaction Decoding) + amounts, steps, deadline, and unless a swap pays only the From address, a + `Paid to` line for each other address it pays, or saying that its output + 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 @@ -2923,7 +2926,34 @@ Currently supported: listed as `Execute Sub-Plan (its steps are not shown)`, and whatever the other steps state, the summary names no swap direction, `Token In` and `Token Out` read `Unknown (could not be decoded)`, and there is no `Amount` or - `Min. received` line. + `Min. received` line. Each swap step, `SWEEP`, `WRAP_ETH`, `UNWRAP_WETH` and + V4 `TAKE` names the address it pays, and `TAKE_ALL` pays the transaction's + sender. The router reads `address(1)` as the transaction's sender, and + `address(2)` as the router itself, which holds what it is paid until a later + step pays it out; a payment to the router, as `address(2)` or by its own + address, is not counted as paid out. When a swap step or `TAKE` pays the + router the swap's output token, the output stays in the router unless a later + `SWEEP` of that token, or a later `UNWRAP_WETH` when it is WETH, pays it out. + A swap step paying an address that the very next step spends, as a V2 exact-in + of the same token whose `amountIn` is `ALREADY_PAID` (zero) or + `CONTRACT_BALANCE`, is a hop, not a payout: Uniswap's mixed routes pay the + next V2 pair directly, and the router enforces that swap's own minimum. Every + other payment counts, in whichever step and of whatever token. The minimum + reads `Min. received` only when some step pays the transaction's sender (the + From address on TxApproval, the sender the explorer reports on + TransactionDetail), no step pays any other address, no step that may pay an + address went unread, and the output does not stay in the router. Otherwise it + reads `Min. paid out`, a `Paid to` line after `Token Out` names each other + address paid, one reads + `The router, where it stays (no later step pays it out)` when the output stays + in the router, and one more reads `Unknown (could not be decoded)` when such a + step went unread, or `Unknown (not named in the calldata)` when no step pays + anyone. A step goes unread when its recipient does not decode as an address, + or when it is a command or V4 action the decoder does not read: every command + other than `PERMIT2_PERMIT`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, + `V3_SWAP_EXACT_IN`, `V4_SWAP`, `SWEEP`, `WRAP_ETH`, `UNWRAP_WETH` and + `BALANCE_CHECK_ERC20` (`V3_SWAP_EXACT_OUT`, `TRANSFER`, `PAY_PORTION` and + `EXECUTE_SUB_PLAN` among them), and V4 `TAKE_PORTION`. ### Non-Goals Forever diff --git a/src/popup/views/approval.js b/src/popup/views/approval.js index 79f2294..8f26992 100644 --- a/src/popup/views/approval.js +++ b/src/popup/views/approval.js @@ -79,10 +79,10 @@ function tokenAmountText(rawAmount, decimals, symbol) { }; } -// Try to decode calldata sent to `toAddress` on the network with chain id -// `chainId`, using known ABIs. +// Try to decode calldata sent from `fromAddress` to `toAddress` on the network +// with chain id `chainId`, using known ABIs. // Returns { name, description, details } or null. -function decodeCalldata(data, toAddress, chainId) { +function decodeCalldata(data, toAddress, chainId, fromAddress) { if (!data || data === "0x" || data.length < 10) return null; // Where a token's scale is looked for, for every decoder below: the ERC-20 @@ -191,6 +191,7 @@ function decodeCalldata(data, toAddress, chainId) { toAddress, chainId, decimalsSources, + fromAddress, ); if (routerResult) return routerResult; @@ -277,11 +278,12 @@ function showTxApproval(details) { // If this is an ERC-20 call, try to extract the real recipient and amount. // Decoded for the network the transaction is for, which the Network line - // names. + // names, and the address the From line names. const decoded = decodeCalldata( approvedTx.data, toAddr || "", approvedTx.chainId, + details.approvedFrom, ); if (decoded && decoded.details) { // The asset the status summary is counted in: an ERC-20 call's Token diff --git a/src/popup/views/transactionDetail.js b/src/popup/views/transactionDetail.js index 004000d..c57a33b 100644 --- a/src/popup/views/transactionDetail.js +++ b/src/popup/views/transactionDetail.js @@ -316,11 +316,13 @@ async function loadFullTxDetails(txHash, chainId) { // Decoded against the address the transaction was sent to, as the // explorer reports it, not the history entry's `to`: for a token // transfer that is the transfer's recipient, not the token contract. - // On the active network, the one whose explorer was just asked. + // On the active network, the one whose explorer was just asked, and + // from the sender it reports. const decoded = decodeCalldata( inputData, txData.to?.hash || "", currentNetwork().chainId, + txData.from?.hash || "", ); if (decoded) { // Render decoded calldata matching approval view style diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index 53b6242..f04113d 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -101,16 +101,23 @@ function formatAmount(raw, decimals) { // One wording for either side of the screen: a currency the calldata never // named. It reads as a refusal, the same stance unknownDecimalsAmount() takes -// on a scale — not as a token name, and not as a quantity. +// on a scale — not as a token name, and not as a quantity. The Paid to line +// uses it too, when no step names anyone paid. const UNNAMED_CURRENCY = "Unknown (not named in the calldata)"; // The same refusal when a V4 swap action could not be read (see // decodeExactly()), or the transaction has an EXECUTE_SUB_PLAN step, whose // 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. +// name it would be false. The Paid to line uses it too, when a step may pay an +// address this decoder did not read. const UNDECODED_CURRENCY = "Unknown (could not be decoded)"; +// The Paid to line when the swap's output is paid to the router and no later +// step pays it out of the router. +const STAYS_IN_ROUTER = + "The router, where it stays (no later step pays it out)"; + // Explicit presence, never truthiness. Every gate in this file that guards a // decoded value goes through here: an address is never falsy once set, but an // amount of 0n is, and a gate that cannot tell a genuine zero from an absent @@ -160,6 +167,11 @@ const OPEN_DELTA = Symbol("v4-open-delta"); // that nothing is swapped. const ALREADY_PAID = Symbol("v2-already-paid"); +// 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. +const CONTRACT_BALANCE = 2n ** 255n; + // The amount lines that state a fact instead of a quantity. Same register as // UNNAMED_CURRENCY — a sentence in the value slot, so it cannot be misread as a // number — and deliberately not another phrasing of "not named": these say @@ -179,6 +191,21 @@ const WETH_ADDRESSES = [ "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", ]; +// Two recipient values the router reads as placeholders, not as addresses: +// MSG_SENDER pays the caller of execute(), which is the transaction's sender, +// and ADDRESS_THIS pays the router itself, which holds what it is paid until a +// later step pays it out. The swap commands, SWEEP, WRAP_ETH and UNWRAP_WETH +// map their recipient through the dispatcher's map(), and a V4 TAKE through +// V4Router's _mapRecipient(); TAKE_ALL always pays the caller. +// - constants: https://github.com/Uniswap/universal-router/blob/cda697da221e24ca143bc4f55023694a2a16796f/contracts/libraries/Constants.sol +// - map(), UniversalRouterV1: https://github.com/Uniswap/universal-router/blob/cda697da221e24ca143bc4f55023694a2a16796f/contracts/libraries/Recipient.sol +// - map(), UniversalRouterV2: https://github.com/Uniswap/universal-router/blob/3663f6db6e2fe121753cd2d899699c2dc75dca86/contracts/base/Dispatcher.sol +// - constants, V4: https://github.com/Uniswap/v4-periphery/blob/444c526b77d804590f0d7bc5a481af5a3277c952/src/libraries/ActionConstants.sol +// - _mapRecipient(): https://github.com/Uniswap/v4-periphery/blob/444c526b77d804590f0d7bc5a481af5a3277c952/src/base/BaseActionsRouter.sol +// - TAKE, TAKE_ALL and TAKE_PORTION: https://github.com/Uniswap/v4-periphery/blob/444c526b77d804590f0d7bc5a481af5a3277c952/src/V4Router.sol +const MSG_SENDER = "0x0000000000000000000000000000000000000001"; +const ADDRESS_THIS = "0x0000000000000000000000000000000000000002"; + // `decimals` is null when nothing knows this token's scale. It is not // defaulted to 18: the swap lines land on the same approval screen as the // ERC-20 line, and a scale guessed there is what showed a 1,000 USDT swap as @@ -265,6 +292,7 @@ function decodeV2SwapExactIn(input) { input, ); return { + recipient: d[0], amountIn: d[1] === 0n ? ALREADY_PAID : d[1], amountOutMin: d[2], tokenIn: d[3][0], @@ -285,6 +313,7 @@ function decodeV2SwapExactOut(input) { input, ); return { + recipient: d[0], amountOut: d[1], amountInMax: d[2], tokenIn: d[3][0], @@ -316,6 +345,7 @@ function decodeV3SwapExactIn(input) { const path = decodeV3Path(d[3]); if (!path) return null; return { + recipient: d[0], amountIn: d[1], amountOutMin: d[2], tokenIn: path.tokenIn, @@ -326,12 +356,37 @@ function decodeV3SwapExactIn(input) { } } -// Decode WRAP_ETH (command 0x0b) input bytes. +// Decode SWEEP (command 0x04) input bytes. The router pays its whole balance +// of `token` to the recipient. +// ABI: (address token, address recipient, uint256 amountMin) +function decodeSweep(input) { + try { + const d = coder.decode(["address", "address", "uint256"], input); + return { token: d[0], recipient: d[1] }; + } catch { + return null; + } +} + +// Decode WRAP_ETH (command 0x0b) input bytes. The router pays the WETH to the +// recipient. // ABI: (address recipient, uint256 amount) function decodeWrapEth(input) { try { const d = coder.decode(["address", "uint256"], input); - return { amount: d[1] }; + return { recipient: d[0], amount: d[1] }; + } catch { + return null; + } +} + +// Decode UNWRAP_WETH (command 0x0c) input bytes. The router turns its whole +// WETH balance into ETH and pays it to the recipient. +// ABI: (address recipient, uint256 amountMin) +function decodeUnwrapWeth(input) { + try { + const d = coder.decode(["address", "uint256"], input); + return { recipient: d[0] }; } catch { return null; } @@ -346,6 +401,7 @@ const V4_SETTLE = 0x0b; const V4_SETTLE_ALL = 0x0c; const V4_TAKE = 0x0e; const V4_TAKE_ALL = 0x0f; +const V4_TAKE_PORTION = 0x10; // A V4 exact-in `amountIn`, read the way V4Router reads it: zero is // ActionConstants.OPEN_DELTA, not a quantity of zero. See the OPEN_DELTA @@ -500,6 +556,10 @@ function totalAmount(amounts) { // these rules leave unknown is set unknown, with no figure. A step with no // swap action sets only the sides SETTLE, SETTLE_ALL, TAKE and TAKE_ALL // name. +// +// `takes` is every TAKE and TAKE_ALL, as the currency it takes and its +// recipient as written, MSG_SENDER for a TAKE_ALL. `recipientUnread` is set +// when the step has a payout this decoder did not read. function decodeV4Swap(input) { try { const d = coder.decode(["bytes", "bytes[]"], input); @@ -508,6 +568,8 @@ function decodeV4Swap(input) { let settleToken = null; let takeToken = null; + const takes = []; + let recipientUnread = false; // The step's swap actions that could be read, as decodeV4SwapAction() // reads them. const swaps = []; @@ -537,10 +599,12 @@ function decodeV4Swap(input) { params[i], ); takeToken = t[0]; + takes.push({ currency: t[0], recipient: t[1] }); } else if (actionId === V4_TAKE_ALL) { // TAKE_ALL: (address currency, uint256 minAmount) const t = coder.decode(["address", "uint256"], params[i]); takeToken = t[0]; + takes.push({ currency: t[0], recipient: MSG_SENDER }); } else if ( actionId === V4_SWAP_EXACT_IN_SINGLE || actionId === V4_SWAP_EXACT_IN || @@ -552,9 +616,19 @@ function decodeV4Swap(input) { } catch { swapUndecoded = true; } + } else if (actionId === V4_TAKE_PORTION) { + // It pays a share of a currency to an address this decoder + // does not read + // (https://git.eeqj.de/sneak/AutistMask/issues/599). + // V4Router reverts on any action not named here. + recipientUnread = true; } } catch { - // Skip sub-actions we can't decode + // Skip sub-actions we can't decode. A TAKE or TAKE_ALL among + // them is a payout this decoder did not read. + if (actionId === V4_TAKE || actionId === V4_TAKE_ALL) { + recipientUnread = true; + } } } @@ -565,6 +639,8 @@ function decodeV4Swap(input) { tokenOut: takeToken, amountIn: null, amountOutMin: null, + takes, + recipientUnread, swapUndecoded, hasSwapAction: true, }; @@ -604,6 +680,8 @@ function decodeV4Swap(input) { tokenOut, amountIn, amountOutMin, + takes, + recipientUnread, swapUndecoded, hasSwapAction: swaps.length > 0, }; @@ -612,14 +690,14 @@ function decodeV4Swap(input) { } } -// Try to decode a Universal Router execute() call sent to `toAddress` on the -// network with chain id `chainId`. +// Try to decode a Universal Router execute() call sent from `fromAddress`, the +// user, to `toAddress` on the network with chain id `chainId`. // Returns { name, description, details } matching the format used by // the approval UI, or null if the calldata is not a recognised execute() or // `toAddress` is not a Universal Router on that network: the same calldata // sent to any other contract is not a swap through the router, whatever that // contract does with it. -function decode(data, toAddress, chainId, sources) { +function decode(data, toAddress, chainId, sources, fromAddress) { const commandTypeMask = routerCommandTypeMask(toAddress, chainId); if (commandTypeMask === null) return null; try { @@ -678,6 +756,57 @@ function decode(data, toAddress, chainId, sources) { outputEstablished = true; }; + // 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 + // out. + const sender = (fromAddress || "").toLowerCase(); + let paidToSender = false; + // Each other address paid, once. + const paidElsewhere = []; + // Set when a step may pay an address this decoder did not read. + let payeeUnread = false; + // The tokens, lowercased, that a swap step or a V4 TAKE paid to the + // router and no later SWEEP of that token, or UNWRAP_WETH of WETH, + // paid out. + const heldByRouter = new Set(); + const isRouter = (recipient) => { + const r = recipient.toLowerCase(); + return r === ADDRESS_THIS || r === toAddress.toLowerCase(); + }; + // A step pays `recipient`. `output` is the token a swap step or a V4 + // TAKE pays; other steps pass none. + const addPayee = (recipient, output) => { + const r = recipient.toLowerCase(); + if (isRouter(recipient)) { + if (present(output)) heldByRouter.add(output.toLowerCase()); + } else if (r === MSG_SENDER || r === sender) { + paidToSender = true; + } else if (!paidElsewhere.some((a) => a.toLowerCase() === r)) { + paidElsewhere.push(recipient); + } + }; + // A swap step that pays `token` to an address the very next step + // spends, as a V2 exact-in of that token whose input is already paid, + // is a hop, not a payout: Uniswap's mixed routes pay the next V2 pair + // directly, and the router enforces that next swap's own minimum. Its + // input is already paid when its amountIn is ALREADY_PAID, or + // CONTRACT_BALANCE, which Uniswap's routes send and with which the + // router adds only what it holds. + const isHop = (i, token) => { + if (i + 1 >= commandsBytes.length) return false; + if ((commandsBytes[i + 1] & commandTypeMask) !== 0x08) return false; + const next = decodeV2SwapExactIn(inputs[i + 1]); + return ( + next !== null && + (next.amountIn === ALREADY_PAID || + next.amountIn === CONTRACT_BALANCE) && + present(token) && + present(next.tokenIn) && + next.tokenIn.toLowerCase() === token.toLowerCase() + ); + }; + for (let i = 0; i < commandsBytes.length; i++) { const cmdId = commandsBytes[i] & commandTypeMask; commandNames.push( @@ -690,30 +819,27 @@ function decode(data, toAddress, chainId, sources) { // EXECUTE_SUB_PLAN runs a further list of commands held in // its input, which this decoder does not read. The step // says so, and after this loop neither side is named. + // Those commands may pay any address. hasSubPlan = true; commandNames[i] += " (its steps are not shown)"; - } - - if (cmdId === 0x0a) { + payeeUnread = true; + } else if (cmdId === 0x0a) { const p = decodePermit2(inputs[i]); // 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 (cmdId === 0x0e) { + } else if (cmdId === 0x0e) { const b = decodeBalanceCheck(inputs[i]); // The router passes this check whenever the owner holds at // least minBalance, so a zero one guarantees nothing and // does not replace a minimum an earlier step stated. Any // other minBalance sets the output side as a swap does. + // It pays nobody. if (b && !(b.minBalance === 0n && present(minOutput))) { setOutput(b.token, b.minBalance); } - } - - if (cmdId === 0x00) { + } else if (cmdId === 0x00) { const s = decodeV3SwapExactIn(inputs[i]); if (s) { setInputOnce(s.tokenIn, s.amountIn); @@ -721,39 +847,69 @@ function decode(data, toAddress, chainId, sources) { // the last swap step determines the final output token // and minimum received amount. setOutput(s.tokenOut, s.amountOutMin); + if (!isHop(i, s.tokenOut)) { + addPayee(s.recipient, s.tokenOut); + } + } else { + payeeUnread = true; } - } - - if (cmdId === 0x08) { + } else if (cmdId === 0x08) { const s = decodeV2SwapExactIn(inputs[i]); if (s) { setInputOnce(s.tokenIn, s.amountIn); setOutput(s.tokenOut, s.amountOutMin); + if (!isHop(i, s.tokenOut)) { + addPayee(s.recipient, s.tokenOut); + } + } else { + payeeUnread = true; } - } - - if (cmdId === 0x09) { + } else if (cmdId === 0x09) { // Buys exactly amountOut and spends at most amountInMax. hasV2ExactOut = true; const s = decodeV2SwapExactOut(inputs[i]); if (s) { setInputOnce(s.tokenIn, s.amountInMax); setOutput(s.tokenOut, s.amountOut); + if (!isHop(i, s.tokenOut)) { + addPayee(s.recipient, s.tokenOut); + } + } else { + payeeUnread = true; } - } - - if (cmdId === 0x0b) { + } else if (cmdId === 0x04) { + const s = decodeSweep(inputs[i]); + if (s) { + addPayee(s.recipient); + if (!isRouter(s.recipient)) { + heldByRouter.delete(s.token.toLowerCase()); + } + } else { + payeeUnread = true; + } + } else if (cmdId === 0x0b) { const w = decodeWrapEth(inputs[i]); if (w) { setInputOnce( "0x0000000000000000000000000000000000000000", w.amount, ); + addPayee(w.recipient); + } else { + payeeUnread = true; } - } - - if (cmdId === 0x10) { + } else if (cmdId === 0x10) { const v4 = decodeV4Swap(inputs[i]); + if (v4) { + for (const t of v4.takes) { + if (!isHop(i, t.currency)) { + addPayee(t.recipient, t.currency); + } + } + } + if (!v4 || v4.recipientUnread) { + payeeUnread = true; + } if (v4 && v4.swapUndecoded) { // A swap action of this step went unread, so the step // is marked. @@ -784,13 +940,29 @@ function decode(data, toAddress, chainId, sources) { setInputOnce(v4.tokenIn, v4.amountIn); setOutput(v4.tokenOut, v4.amountOutMin); } - } - - if (cmdId === 0x0c) { + } else if (cmdId === 0x0c) { hasUnwrapWeth = true; + const u = decodeUnwrapWeth(inputs[i]); + if (u) { + addPayee(u.recipient); + if (!isRouter(u.recipient)) { + WETH_ADDRESSES.forEach((w) => + heldByRouter.delete(w), + ); + } + } else { + payeeUnread = true; + } + } else { + // Any other command may pay an address this decoder does + // not read: V3_SWAP_EXACT_OUT does, and so do TRANSFER, + // PAY_PORTION and the others + // https://git.eeqj.de/sneak/AutistMask/issues/599 lists. + payeeUnread = true; } } catch { - // Skip commands we can't decode + // Skip commands we can't decode. One may pay any address. + payeeUnread = true; } } @@ -936,6 +1108,31 @@ function decode(data, toAddress, chainId, sources) { }); } + // The output is received by the sender only when some step pays the + // sender, none pays another address, none may pay an address this + // decoder did not read, and the output token is not left in the + // router. Otherwise each other address paid is named, and further + // lines say when the output stays in the router, a payout could not + // be read, or no step names anyone paid. + const outputHeld = + present(outputToken) && heldByRouter.has(outputToken.toLowerCase()); + const received = + paidToSender && + paidElsewhere.length === 0 && + !payeeUnread && + !outputHeld; + for (const address of paidElsewhere) { + details.push({ label: "Paid to", value: address, address }); + } + if (outputHeld) { + details.push({ label: "Paid to", value: STAYS_IN_ROUTER }); + } + if (payeeUnread) { + details.push({ label: "Paid to", value: UNDECODED_CURRENCY }); + } else if (!paidToSender && paidElsewhere.length === 0 && !outputHeld) { + details.push({ label: "Paid to", value: UNNAMED_CURRENCY }); + } + if (present(minOutput)) { // A zero floor is the one case the user most needs stated: the // swap guarantees nothing back. It is said in words, in the same @@ -944,7 +1141,7 @@ function decode(data, toAddress, chainId, sources) { // return nothing" — and because it is true at every scale, so it // holds even when the output token's decimals are unknown. details.push({ - label: "Min. received", + label: received ? "Min. received" : "Min. paid out", value: minOutput === 0n ? NO_MINIMUM diff --git a/tests/approvalDisplayFloor.test.js b/tests/approvalDisplayFloor.test.js index d131b3c..044e9da 100644 --- a/tests/approvalDisplayFloor.test.js +++ b/tests/approvalDisplayFloor.test.js @@ -68,10 +68,13 @@ function swapData(amountIn, amountOutMin) { } function swapDetail(amountIn, amountOutMin, label) { + // Sent by the address the swap pays, so the output is the user's. const decoded = uniswap.decode( swapData(amountIn, amountOutMin), ROUTER, "0x1", + {}, + RECIPIENT, ); return decoded.details.find((d) => d.label === label); } diff --git a/tests/uniswap.test.js b/tests/uniswap.test.js index 098ff41..79f8cd2 100644 --- a/tests/uniswap.test.js +++ b/tests/uniswap.test.js @@ -332,7 +332,13 @@ describe("uniswap decoder", () => { }); test("decodes first-ever AutistMask swap (PERMIT2_PERMIT + V4_SWAP)", () => { - const result = uniswap.decode(FIRST_SWAP_CALLDATA, ROUTER_ADDR, "0x1"); + const result = uniswap.decode( + FIRST_SWAP_CALLDATA, + ROUTER_ADDR, + "0x1", + {}, + USER_ADDR, + ); expect(result).not.toBeNull(); expect(result.name).toBe("Swap USDT \u2192 ETH"); @@ -356,8 +362,10 @@ describe("uniswap decoder", () => { expect(result.details.find((d) => d.label === "Amount").value).toBe( "Unlimited", ); + // This fixture's TAKE parameters are one byte long, so who it pays + // cannot be read, and the minimum is not said to be received. expect( - result.details.find((d) => d.label === "Min. received").value, + result.details.find((d) => d.label === "Min. paid out").value, ).toBe("0.0002 ETH"); // Real mainnet calldata is in the deployed shape, so its V4 step is @@ -373,6 +381,8 @@ describe("uniswap decoder", () => { v4SwapCalldata(id, deployed), ROUTER_ADDR, "0x1", + {}, + USER_ADDR, ); expect(detail(result, "Steps").value).toBe("V4 Swap"); }, @@ -389,6 +399,8 @@ describe("uniswap decoder", () => { v4SwapCalldata(id, newer), ROUTER_ADDR, "0x1", + {}, + USER_ADDR, ); expect(detail(result, "Amount")).toBeUndefined(); expect(detail(result, "Min. received")).toBeUndefined(); @@ -419,7 +431,13 @@ describe("uniswap decoder", () => { ], 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode( + data, + ROUTER_ADDR, + "0x1", + {}, + USER_ADDR, + ); expect(detail(result, "Steps").value).toBe( "V4 Swap (could not be decoded)", ); @@ -440,7 +458,13 @@ describe("uniswap decoder", () => { [encodeV4Swap(new Uint8Array([id]), [newer])], 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode( + data, + ROUTER_ADDR, + "0x1", + {}, + USER_ADDR, + ); expect(detail(result, "Steps").value).toBe( "V4 Swap (could not be decoded)", ); @@ -470,7 +494,13 @@ describe("uniswap decoder", () => { ], 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode( + data, + ROUTER_ADDR, + "0x1", + {}, + USER_ADDR, + ); expect(result.name).toBe("Uniswap Swap"); expect(detail(result, "Amount").value).toBe("2.0000 USDT"); expect(detail(result, "Token Out").value).toBe( @@ -498,7 +528,13 @@ describe("uniswap decoder", () => { ], 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode( + data, + ROUTER_ADDR, + "0x1", + {}, + USER_ADDR, + ); expect(result.name).toBe("Uniswap Swap"); expect(detail(result, "Token In").value).toBe( "Unknown (could not be decoded)", @@ -520,7 +556,7 @@ describe("uniswap decoder", () => { ], 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(detail(result, "Token Out").value).toBe( "Unknown (could not be decoded)", ); @@ -540,7 +576,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); expect(result.name).toBe("Swap USDT \u2192 WETH"); @@ -567,7 +603,7 @@ describe("uniswap decoder", () => { 1767225600n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result.name).toBe("Swap USDC → WETH"); expect(detail(result, "Token In").address).toBe(USDC_ADDR); expect(detail(result, "Token Out").address).toBe(WETH_ADDR); @@ -599,7 +635,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result.name).toBe("Swap ETH → USDC"); const amount = detail(result, "Amount"); @@ -628,7 +664,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result.name).toBe("Swap USDC → ETH"); expect(detail(result, "Amount").value).toBe("Up to 1500.0000 USDC"); expect(detail(result, "Token Out").value).toBe("ETH"); @@ -671,7 +707,13 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode( + data, + ROUTER_ADDR, + "0x1", + {}, + USER_ADDR, + ); expect(detail(result, "Token Out").address).toBe(tokenOut); expect(detail(result, "Min. received").value).toBe(minReceived); }, @@ -692,7 +734,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result.name).toBe("Swap USDT → USDC"); expect(detail(result, "Token Out").address).toBe(USDC_ADDR); expect(detail(result, "Min. received").value).toBe("1.9000 USDC"); @@ -725,7 +767,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(detail(result, "Token Out").value).toBe("ETH"); expect(detail(result, "Min. received").value).toBe("0.4000 ETH"); }); @@ -744,7 +786,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(detail(result, "Token Out").value).toBe("ETH"); expect(detail(result, "Min. received").value).toBe("0.0005 ETH"); }); @@ -769,7 +811,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(detail(result, "Token Out").value).toBe( "Unknown (not named in the calldata)", ); @@ -792,7 +834,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); expect(result.name).toBe("Swap USDT \u2192 WETH"); }); @@ -804,7 +846,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); const tokenIn = result.details.find((d) => d.label === "Token In"); @@ -827,12 +869,13 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); // UNWRAP_WETH means output is native ETH expect(result.name).toBe("Swap USDT \u2192 ETH"); }); + // No step pays anyone, so the minimum is not said to be received. test("decodes BALANCE_CHECK_ERC20 for min output", () => { const data = buildExecute( solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]), @@ -843,10 +886,10 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); - const minOut = result.details.find((d) => d.label === "Min. received"); + const minOut = result.details.find((d) => d.label === "Min. paid out"); expect(minOut).toBeDefined(); expect(minOut.value).toContain("2.0000"); expect(minOut.value).toContain("USDT"); @@ -863,7 +906,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); // The V4 swap action's parameters are empty, so that step is said to @@ -943,7 +986,13 @@ describe("uniswap decoder", () => { [subPlan], 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode( + data, + ROUTER_ADDR, + "0x1", + {}, + USER_ADDR, + ); expect(result.name).toBe("Uniswap Swap"); expect(detail(result, "Steps").value).toBe( "Execute Sub-Plan (its steps are not shown)", @@ -1031,7 +1080,13 @@ describe("uniswap decoder", () => { "$beside an EXECUTE_SUB_PLAN step does not name either side", ({ commands, inputs, steps }) => { const data = buildExecute(commands, inputs, 9999999999n); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode( + data, + ROUTER_ADDR, + "0x1", + {}, + USER_ADDR, + ); expect(detail(result, "Steps").value).toBe(steps); expect(result.name).toBe("Uniswap Swap"); expect(detail(result, "Token In").value).toBe( @@ -1046,7 +1101,13 @@ describe("uniswap decoder", () => { ); test("shows the deadline as a UTC date and time", () => { - const result = uniswap.decode(FIRST_SWAP_CALLDATA, ROUTER_ADDR, "0x1"); + const result = uniswap.decode( + FIRST_SWAP_CALLDATA, + ROUTER_ADDR, + "0x1", + {}, + USER_ADDR, + ); expect(detail(result, "Deadline").value).toBe("2026-02-27 08:25:51"); }); @@ -1064,7 +1125,7 @@ describe("uniswap decoder", () => { 2n ** 256n - 1n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); expect(result.name).toBe("Swap USDT \u2192 WETH"); expect(detail(result, "Deadline").value).toBe( @@ -1079,7 +1140,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); const amount = result.details.find((d) => d.label === "Amount"); @@ -1154,7 +1215,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); // Before decodeV4Swap fix: name would be "Swap USDT → ETH" or "Uniswap Swap" // After fix: correctly identifies both tokens from V4 sub-actions @@ -1188,7 +1249,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); // The amount and the token it is counted in come from the same hop. @@ -1228,7 +1289,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); expect(detail(result, "Token Out").value).toBe( @@ -1273,7 +1334,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); expect(detail(result, "Token Out").value).toContain("USDC"); @@ -1302,7 +1363,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); expect(detail(result, "Token Out").value).toContain("USDC"); @@ -1339,7 +1400,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); expect(detail(result, "Token In").value).toContain("WETH"); @@ -1356,9 +1417,12 @@ describe("uniswap decoder", () => { // are written. Each is tried with three endings: SETTLE_ALL and TAKE_ALL, // SETTLE and TAKE, and CLOSE_CURRENCY for each currency, which leaves the // sides to the swap actions alone. + // `minLabel` is the minimum's line: with no TAKE or TAKE_ALL, no step + // pays the user, so it is not said to be received. const V4_STEP_ENDINGS = [ { ending: "SETTLE_ALL and TAKE_ALL", + minLabel: "Min. received", ids: [V4_SETTLE_ALL_ID, V4_TAKE_ALL_ID], params: [ encodeV4SettleAll(USDT_ADDR, 1000000000n), @@ -1367,11 +1431,13 @@ describe("uniswap decoder", () => { }, { ending: "SETTLE and TAKE", + minLabel: "Min. received", ids: [V4_SETTLE_ID, V4_TAKE_ID], params: [encodeV4Settle(USDT_ADDR), encodeV4Take(USDC_ADDR)], }, { ending: "CLOSE_CURRENCY", + minLabel: "Min. paid out", ids: [ V4_CLOSE_CURRENCY_ID, V4_CLOSE_CURRENCY_ID, @@ -1520,7 +1586,7 @@ describe("uniswap decoder", () => { ), )( "a V4 step of $step, then $ending, reads Swap USDT → USDC", - ({ swaps, ids, params, amount, min }) => { + ({ swaps, ids, params, amount, min, minLabel }) => { const data = buildExecute( "0x10", [ @@ -1532,12 +1598,18 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode( + data, + ROUTER_ADDR, + "0x1", + {}, + USER_ADDR, + ); expect(result.name).toBe("Swap USDT → USDC"); expect(detail(result, "Token In").address).toBe(USDT_ADDR); expect(detail(result, "Token Out").address).toBe(USDC_ADDR); expect(detail(result, "Amount")?.value).toBe(amount); - expect(detail(result, "Min. received")?.value).toBe(min); + expect(detail(result, minLabel)?.value).toBe(min); }, ); @@ -1584,7 +1656,13 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode( + data, + ROUTER_ADDR, + "0x1", + {}, + USER_ADDR, + ); expect(detail(result, "Steps").value).toBe( "V4 Swap (could not be decoded)", ); @@ -1643,7 +1721,7 @@ describe("uniswap decoder", () => { ], 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result.name).toBe("Uniswap Swap"); expect(detail(result, "Token In").address).toBe( USDT_ADDR.toLowerCase(), @@ -1699,7 +1777,7 @@ describe("uniswap decoder", () => { ], 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result.name).toBe("Uniswap Swap"); expect(detail(result, "Token In").value).toBe( "Unknown (not named in the calldata)", @@ -1722,7 +1800,7 @@ describe("uniswap decoder", () => { ], 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(detail(result, "Token Out").value).toBe( "Unknown (not named in the calldata)", ); @@ -1756,7 +1834,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result.name).toBe("Swap USDT → USDC"); expect(detail(result, "Token In").address).toBe(USDT_ADDR); expect(detail(result, "Token Out").address).toBe(USDC_ADDR); @@ -1780,7 +1858,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); expect(detail(result, "Token In").value).toContain("USDT"); @@ -1811,7 +1889,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); expect(detail(result, "Token Out").value).toContain("WETH"); @@ -1832,7 +1910,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); expect(detail(result, "Token Out").value).toContain("USDC"); @@ -1840,7 +1918,8 @@ describe("uniswap decoder", () => { }); // With no minimum stated before it, a zero balance check is what sets the - // output side, and it guarantees nothing. + // output side, and it guarantees nothing. No step pays anyone, so the + // minimum is not said to be received. test("a zero balance check with no earlier minimum states no minimum", () => { const data = buildExecute( solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]), @@ -1851,11 +1930,11 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); expect(detail(result, "Token Out").value).toContain("USDT"); - expect(detail(result, "Min. received").value).toBe( + expect(detail(result, "Min. paid out").value).toBe( "None (no minimum guaranteed)", ); }); @@ -1871,7 +1950,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); expect(result.name).toBe("Uniswap Swap"); @@ -1891,7 +1970,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); expect(result.name).toBe("Uniswap Swap"); diff --git a/tests/uniswapRecipient.test.js b/tests/uniswapRecipient.test.js new file mode 100644 index 0000000..99df041 --- /dev/null +++ b/tests/uniswapRecipient.test.js @@ -0,0 +1,856 @@ +// The address a swap's output is paid to +// (https://git.eeqj.de/sneak/AutistMask/issues/594). +// +// Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH and V4 TAKE names the +// address it pays, and TAKE_ALL pays the sender. The decoder read none of +// them: a swap whose output went to another address still read "Min. +// received", as if the user received it. The router reads address(1) as the +// sender and address(2) as the router itself, which holds what it is paid +// until a later step pays it out. +// +// The screens are driven against a minimal DOM stub in the shape +// tests/uniswapRouterAddress.test.js uses. + +const { AbiCoder, Interface, solidityPacked } = require("ethers"); + +jest.mock("../src/shared/log", () => ({ + log: { + debugf: () => {}, + infof: () => {}, + warnf: () => {}, + errorf: () => {}, + }, + debugFetch: async (url) => ({ + ok: true, + json: async () => mockExplorerAnswer(url), + }), + setRuntimeDebug: () => {}, + isDebug: () => false, +})); + +globalThis.chrome = { + storage: { local: { get: async () => ({}), set: async () => {} } }, +}; + +const uniswap = require("../src/shared/uniswap"); +const { state } = require("../src/shared/state"); +const approval = require("../src/popup/views/approval"); +const transactionDetail = require("../src/popup/views/transactionDetail"); + +// UniversalRouterV2 on Ethereum Mainnet. +const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af"; +// UniversalRouterV2_1_2 on Ethereum Mainnet, whose mask reads 0x07 and 0x40. +const ROUTER_V2_1_2 = "0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85"; +const USER = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; +const OTHER = "0x7777777777777777777777777777777777777777"; +const MSG_SENDER = "0x0000000000000000000000000000000000000001"; +const ADDRESS_THIS = "0x0000000000000000000000000000000000000002"; +const ZERO = "0x0000000000000000000000000000000000000000"; + +const USDT = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; +const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; +const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; +const ONE_WETH = 1000000000000000000n; +const USDT_2000 = 2000000000n; +const USDC_1990 = 1990000000n; + +// The Paid to line's value when no address can be named. +const UNDECODED = "Unknown (could not be decoded)"; +const UNNAMED = "Unknown (not named in the calldata)"; +const STAYS_IN_ROUTER = + "The router, where it stays (no later step pays it out)"; + +const coder = AbiCoder.defaultAbiCoder(); +const routerIface = new Interface([ + "function execute(bytes commands, bytes[] inputs, uint256 deadline)", +]); + +// execute() of `steps`, each [command, input]. +function execute(...steps) { + const commands = steps.map(([command]) => command); + return routerIface.encodeFunctionData("execute", [ + solidityPacked( + commands.map(() => "uint8"), + commands, + ), + steps.map(([, input]) => input), + 9999999999n, + ]); +} + +// V2_SWAP_EXACT_IN (0x08) and V2_SWAP_EXACT_OUT (0x09) share one layout. +function v2Swap(command, recipient, amount, limit, path, payerIsUser = true) { + return [ + command, + coder.encode( + ["address", "uint256", "uint256", "address[]", "bool"], + [recipient, amount, limit, path, payerIsUser], + ), + ]; +} + +function v3SwapExactIn(recipient, amountIn, amountOutMin, tokenIn, tokenOut) { + return [ + 0x00, + coder.encode( + ["address", "uint256", "uint256", "bytes", "bool"], + [ + recipient, + amountIn, + amountOutMin, + solidityPacked( + ["address", "uint24", "address"], + [tokenIn, 3000, tokenOut], + ), + true, + ], + ), + ]; +} + +function sweep(token, recipient) { + return [ + 0x04, + coder.encode(["address", "address", "uint256"], [token, recipient, 0n]), + ]; +} + +function wrapEth(recipient, amount) { + return [0x0b, coder.encode(["address", "uint256"], [recipient, amount])]; +} + +function unwrapWeth(recipient) { + return [0x0c, coder.encode(["address", "uint256"], [recipient, 0n])]; +} + +function balanceCheck(token, minBalance) { + return [ + 0x0e, + coder.encode( + ["address", "address", "uint256"], + [USER, token, minBalance], + ), + ]; +} + +// `step`, a [command or action, input] pair, with the top byte of the input's +// word `index` set. The decoder cannot read that word as an address. +function withDirtyWord([id, input], index) { + const at = 2 + index * 64; + return [id, input.slice(0, at) + "ff" + input.slice(at + 2)]; +} + +// The V4 actions a step below is built from. +const SWAP_USDT_FOR_USDC = [ + 0x06, // SWAP_EXACT_IN_SINGLE + coder.encode( + [ + "tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)", + ], + [[[USDT, USDC, 100, 1, ZERO], true, USDT_2000, USDC_1990, "0x"]], + ), +]; +// SWAP_EXACT_IN_SINGLE with no parameters, which cannot be read. +const UNREADABLE_SWAP = [0x06, "0x"]; +const SETTLE_USDT = [ + 0x0b, + coder.encode(["address", "uint256", "bool"], [USDT, 0n, true]), +]; +function take(currency, recipient) { + return [ + 0x0e, + coder.encode( + ["address", "address", "uint256"], + [currency, recipient, 0n], + ), + ]; +} +function takeAll(currency) { + return [0x0f, coder.encode(["address", "uint256"], [currency, 0n])]; +} + +// A V4_SWAP step of `actions`, each [action, params]. +function v4Swap(...actions) { + return [ + 0x10, + coder.encode( + ["bytes", "bytes[]"], + [ + solidityPacked( + actions.map(() => "uint8"), + actions.map(([action]) => action), + ), + actions.map(([, params]) => params), + ], + ), + ]; +} + +function decode(data, sender = USER, router = ROUTER) { + return uniswap.decode(data, router, "0x1", {}, sender); +} + +function labels(result) { + return result.details.map((d) => d.label); +} + +function detail(result, label) { + return result.details.find((d) => d.label === label); +} + +function paidTo(result) { + return result.details + .filter((d) => d.label === "Paid to") + .map((d) => d.value); +} + +// A swap paid to the user, the lines it has always had. +const PAID_TO_USER = [ + "Protocol", + "Token In", + "Amount", + "Token Out", + "Min. received", + "Steps", + "Deadline", +]; +const PAID_ELSEWHERE = [ + "Protocol", + "Token In", + "Amount", + "Token Out", + "Paid to", + "Min. paid out", + "Steps", + "Deadline", +]; + +// A swap of 2,000 USDT for at least 1 WETH, paid to `recipient`. +const swapToWeth = (recipient) => + v3SwapExactIn(recipient, USDT_2000, ONE_WETH, USDT, WETH); + +beforeEach(() => { + state.trackedTokens = []; + state.wallets = []; +}); + +describe("each swap command's recipient", () => { + test.each([ + { + kind: "V2_SWAP_EXACT_IN", + data: (r) => + execute(v2Swap(0x08, r, USDT_2000, ONE_WETH, [USDT, WETH])), + min: "1.0000 WETH", + }, + { + kind: "V2_SWAP_EXACT_OUT", + data: (r) => + execute(v2Swap(0x09, r, ONE_WETH, USDT_2000, [USDT, WETH])), + min: "1.0000 WETH", + }, + { + kind: "V3_SWAP_EXACT_IN", + data: (r) => execute(swapToWeth(r)), + min: "1.0000 WETH", + }, + { + kind: "V4_SWAP with TAKE", + data: (r) => + execute(v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, take(USDC, r))), + min: "1990.0000 USDC", + }, + ])( + "$kind: paid to the user it reads as before; paid to another address it names that address", + ({ data, min }) => { + const viaPlaceholder = decode(data(MSG_SENDER)); + expect(labels(viaPlaceholder)).toEqual(PAID_TO_USER); + expect(detail(viaPlaceholder, "Min. received").value).toBe(min); + // The sender's own address, in any case, reads the same. + expect(decode(data(USER), USER.toLowerCase()).details).toEqual( + viaPlaceholder.details, + ); + + const toOther = decode(data(OTHER)); + expect(labels(toOther)).toEqual(PAID_ELSEWHERE); + expect(paidTo(toOther)).toEqual([OTHER]); + expect(detail(toOther, "Min. paid out").value).toBe(min); + }, + ); +}); + +describe("an output the router holds", () => { + const toRouter = v3SwapExactIn( + ADDRESS_THIS, + USDT_2000, + USDC_1990, + USDT, + USDC, + ); + + test("SWEEP pays it to the SWEEP's recipient", () => { + const direct = decode( + execute( + v3SwapExactIn(MSG_SENDER, USDT_2000, USDC_1990, USDT, USDC), + ), + ); + const toUser = decode(execute(toRouter, sweep(USDC, MSG_SENDER))); + expect(labels(toUser)).toEqual(PAID_TO_USER); + const withoutSteps = (r) => + r.details.filter((d) => d.label !== "Steps"); + expect(withoutSteps(toUser)).toEqual(withoutSteps(direct)); + + const toOther = decode(execute(toRouter, sweep(USDC, OTHER))); + expect(labels(toOther)).toEqual(PAID_ELSEWHERE); + expect(paidTo(toOther)).toEqual([OTHER]); + expect(detail(toOther, "Min. paid out").value).toBe("1990.0000 USDC"); + }); + + test("with nothing paying it out, it stays in the router", () => { + const result = decode(execute(toRouter)); + expect(labels(result)).toEqual(PAID_ELSEWHERE); + expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]); + }); + + test("the router's own address as a recipient is read as address(2) is", () => { + const toRouterAddress = v3SwapExactIn( + ROUTER, + USDT_2000, + USDC_1990, + USDT, + USDC, + ); + expect(paidTo(decode(execute(toRouterAddress)))).toEqual([ + STAYS_IN_ROUTER, + ]); + expect( + labels(decode(execute(toRouterAddress, sweep(USDC, MSG_SENDER)))), + ).toEqual(PAID_TO_USER); + }); + + test("UNWRAP_WETH pays its WETH, as ETH, to the UNWRAP_WETH's recipient", () => { + const toWeth = swapToWeth(ADDRESS_THIS); + + const toUser = decode(execute(toWeth, unwrapWeth(MSG_SENDER))); + expect(labels(toUser)).toEqual(PAID_TO_USER); + expect(detail(toUser, "Token Out").value).toBe("ETH"); + expect(detail(toUser, "Min. received").value).toBe("1.0000 ETH"); + + const toOther = decode(execute(toWeth, unwrapWeth(OTHER))); + expect(labels(toOther)).toEqual(PAID_ELSEWHERE); + expect(paidTo(toOther)).toEqual([OTHER]); + expect(detail(toOther, "Min. paid out").value).toBe("1.0000 ETH"); + }); + + test("WRAP_ETH pays its WETH to the WRAP_ETH's recipient", () => { + const toUser = decode( + execute(wrapEth(ADDRESS_THIS, ONE_WETH), unwrapWeth(MSG_SENDER)), + ); + expect(detail(toUser, "Token Out").value).toBe("ETH"); + expect(paidTo(toUser)).toEqual([]); + + const toOther = decode( + execute(wrapEth(OTHER, ONE_WETH), unwrapWeth(MSG_SENDER)), + ); + expect(paidTo(toOther)).toEqual([OTHER]); + }); + + test("a V4 TAKE to the router, then SWEEP, pays the SWEEP's recipient", () => { + const step = v4Swap( + SWAP_USDT_FOR_USDC, + SETTLE_USDT, + take(USDC, ADDRESS_THIS), + ); + + const toUser = decode(execute(step, sweep(USDC, MSG_SENDER))); + expect(labels(toUser)).toEqual(PAID_TO_USER); + + const toOther = decode(execute(step, sweep(USDC, OTHER))); + expect(labels(toOther)).toEqual(PAID_ELSEWHERE); + expect(paidTo(toOther)).toEqual([OTHER]); + }); +}); + +// Only a later SWEEP of the output token, or UNWRAP_WETH of WETH, pays the +// router's output out. A payment to the user of another token, of nothing, or +// before the swap leaves the output in the router. +describe("an output left in the router is not received", () => { + const toRouter = swapToWeth(ADDRESS_THIS); + + test.each([ + { + kind: "a SWEEP of WETH to the user, then the swap", + data: execute(sweep(WETH, MSG_SENDER), toRouter), + min: "1.0000 WETH", + }, + { + kind: "an UNWRAP_WETH to the user, then the swap", + data: execute(unwrapWeth(MSG_SENDER), toRouter), + min: "1.0000 ETH", + }, + { + kind: "the swap, then a WRAP_ETH of zero to the user", + data: execute(toRouter, wrapEth(MSG_SENDER, 0n)), + min: "1.0000 WETH", + }, + { + kind: "the swap, then a SWEEP of a token it does not buy to the user", + data: execute(toRouter, sweep(USDC, MSG_SENDER)), + min: "1.0000 WETH", + }, + ])("$kind", ({ data, min }) => { + const result = decode(data); + expect(labels(result)).toEqual(PAID_ELSEWHERE); + expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]); + expect(detail(result, "Min. paid out").value).toBe(min); + }); + + test("one V4 step: a TAKE of what it buys to the router, then a TAKE_ALL of it, which takes nothing", () => { + const result = decode( + execute( + v4Swap( + SWAP_USDT_FOR_USDC, + SETTLE_USDT, + take(USDC, ADDRESS_THIS), + takeAll(USDC), + ), + ), + ); + expect(detail(result, "Steps").value).toBe("V4 Swap"); + expect(labels(result)).toEqual(PAID_ELSEWHERE); + expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]); + expect(detail(result, "Min. paid out").value).toBe("1990.0000 USDC"); + }); +}); + +// Uniswap's mixed routes pay a V2 pair directly when the next step swaps +// through it. That V2 swap spends what the pair holds: its amountIn is +// CONTRACT_BALANCE, paid from the router, or ALREADY_PAID. +describe("a swap paid to the pair the next V2 swap spends", () => { + const PAIR = "0x5555555555555555555555555555555555555555"; + const CONTRACT_BALANCE = 2n ** 255n; + const swapToPair = v3SwapExactIn(PAIR, USDT_2000, 0n, USDT, WETH); + const pairSpends = (amountIn, path) => + v2Swap(0x08, MSG_SENDER, amountIn, USDC_1990, path, false); + + test.each([ + { kind: "CONTRACT_BALANCE", amountIn: CONTRACT_BALANCE }, + { kind: "ALREADY_PAID", amountIn: 0n }, + ])( + "a V3 swap, then a V2 swap whose amountIn is $kind, reads as a swap paid to the user", + ({ amountIn }) => { + const result = decode( + execute(swapToPair, pairSpends(amountIn, [WETH, USDC])), + ); + expect(result.name).toBe("Swap USDT → USDC"); + expect(labels(result)).toEqual(PAID_TO_USER); + expect(detail(result, "Amount").value).toBe("2000.0000 USDT"); + expect(detail(result, "Min. received").value).toBe( + "1990.0000 USDC", + ); + }, + ); + + test("a V4 step whose TAKE pays the pair, then the V2 swap, reads the same", () => { + const result = decode( + execute( + v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, take(USDC, PAIR)), + v2Swap( + 0x08, + MSG_SENDER, + CONTRACT_BALANCE, + ONE_WETH, + [USDC, WETH], + false, + ), + ), + ); + expect(labels(result)).toEqual(PAID_TO_USER); + expect(detail(result, "Min. received").value).toBe("1.0000 WETH"); + }); + + test.each([ + { + kind: "takes its input from the user", + next: v2Swap(0x08, MSG_SENDER, ONE_WETH, USDC_1990, [WETH, USDC]), + }, + { + kind: "spends another token", + next: pairSpends(0n, [USDC, WETH]), + }, + ])("when the V2 swap $kind, the pair is named", ({ next }) => { + const result = decode(execute(swapToPair, next)); + expect(paidTo(result)).toEqual([PAIR]); + expect(detail(result, "Min. received")).toBeUndefined(); + }); +}); + +describe("every step's payee counts, whichever step sets the output", () => { + test("TAKE_ALL pays the user; a TAKE to another address beside it is named", () => { + const toUser = decode( + execute(v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, takeAll(USDC))), + ); + expect(labels(toUser)).toEqual(PAID_TO_USER); + + // TAKE with an amount of zero takes the whole open credit, so + // TAKE_ALL after it pays the user nothing. + const split = decode( + execute( + v4Swap( + SWAP_USDT_FOR_USDC, + SETTLE_USDT, + take(USDC, OTHER), + takeAll(USDC), + ), + ), + ); + expect(paidTo(split)).toEqual([OTHER]); + expect(detail(split, "Min. received")).toBeUndefined(); + expect(detail(split, "Min. paid out").value).toBe("1990.0000 USDC"); + }); + + test("a swap paid to another address, then a V4 step that only takes the same token", () => { + const emptyStep = v4Swap(takeAll(WETH)); + + const result = decode(execute(swapToWeth(OTHER), emptyStep)); + expect(paidTo(result)).toEqual([OTHER]); + expect(detail(result, "Min. received")).toBeUndefined(); + + const checked = decode( + execute(swapToWeth(OTHER), emptyStep, balanceCheck(WETH, ONE_WETH)), + ); + expect(paidTo(checked)).toEqual([OTHER]); + expect(detail(checked, "Min. received")).toBeUndefined(); + expect(detail(checked, "Min. paid out").value).toBe("1.0000 WETH"); + }); + + test("a swap paid to the router, a V4 step that only takes, then SWEEP to another address", () => { + const result = decode( + execute( + v3SwapExactIn(ADDRESS_THIS, USDT_2000, USDC_1990, USDT, USDC), + v4Swap(takeAll(USDC)), + sweep(USDC, OTHER), + ), + ); + expect(paidTo(result)).toEqual([OTHER]); + expect(detail(result, "Min. received")).toBeUndefined(); + }); + + test("a swap paid to another address, then a small swap paid to the user", () => { + const result = decode( + execute( + swapToWeth(OTHER), + v2Swap(0x08, MSG_SENDER, 1000000n, 1n, [USDT, WETH]), + ), + ); + expect(paidTo(result)).toEqual([OTHER]); + expect(detail(result, "Min. received")).toBeUndefined(); + expect(detail(result, "Min. paid out")).toBeDefined(); + }); + + test("one V4 step: a TAKE of what it buys to another address, then TAKE_ALL of another currency", () => { + const result = decode( + execute( + v4Swap( + SWAP_USDT_FOR_USDC, + SETTLE_USDT, + take(USDC, OTHER), + takeAll(USDT), + ), + ), + ); + expect(paidTo(result)).toEqual([OTHER]); + expect(detail(result, "Min. received")).toBeUndefined(); + }); +}); + +describe("a payout that cannot be read is never said to be received", () => { + test("a V4 TAKE whose recipient word has its top byte set", () => { + const result = decode( + execute( + v4Swap( + SWAP_USDT_FOR_USDC, + SETTLE_USDT, + withDirtyWord(take(USDC, OTHER), 1), + ), + ), + ); + expect(paidTo(result)).toEqual([UNDECODED]); + expect(detail(result, "Min. received")).toBeUndefined(); + expect(detail(result, "Min. paid out").value).toBe("1990.0000 USDC"); + }); + + test.each([ + { + kind: "V2_SWAP_EXACT_IN", + step: v2Swap(0x08, OTHER, 1n, 1n, [USDT, WETH]), + }, + { + kind: "V2_SWAP_EXACT_OUT", + step: v2Swap(0x09, OTHER, 1n, 1n, [USDT, WETH]), + }, + { kind: "V3_SWAP_EXACT_IN", step: swapToWeth(OTHER) }, + { kind: "SWEEP", step: sweep(WETH, OTHER), word: 1 }, + { kind: "WRAP_ETH", step: wrapEth(OTHER, 1n) }, + { kind: "UNWRAP_WETH", step: unwrapWeth(OTHER) }, + ])( + "a $kind whose recipient word has its top byte set", + ({ step, word = 0 }) => { + const result = decode( + execute(swapToWeth(MSG_SENDER), withDirtyWord(step, word)), + ); + expect(paidTo(result)).toEqual([UNDECODED]); + expect(detail(result, "Min. received")).toBeUndefined(); + expect(detail(result, "Min. paid out")).toBeDefined(); + }, + ); + + // The router reads only the low 32 bits of the action list's length, so + // the step still runs, and may pay anyone. + test("a V4_SWAP whose action list's length word has its top byte set", () => { + const step = v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, take(USDC, OTHER)); + const result = decode( + execute(swapToWeth(MSG_SENDER), withDirtyWord(step, 2)), + ); + expect(paidTo(result)).toEqual([UNDECODED]); + expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH"); + }); + + test("an output set only by BALANCE_CHECK_ERC20", () => { + const result = decode(execute(balanceCheck(WETH, ONE_WETH))); + expect(paidTo(result)).toEqual([UNNAMED]); + expect(detail(result, "Min. received")).toBeUndefined(); + expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH"); + }); + + // https://git.eeqj.de/sneak/AutistMask/issues/599 is to read these. Until + // then each is a payout this decoder does not read. + const transferShape = (command) => [ + command, + coder.encode(["address", "address", "uint256"], [WETH, OTHER, 1n]), + ]; + test.each([ + { kind: "PERMIT2_TRANSFER_FROM", step: transferShape(0x02) }, + { kind: "TRANSFER", step: transferShape(0x05) }, + { kind: "PAY_PORTION", step: transferShape(0x06) }, + { kind: "PAY_PORTION_FULL_PRECISION", step: transferShape(0x07) }, + { + kind: "PERMIT2_TRANSFER_FROM_BATCH", + step: [ + 0x0d, + coder.encode( + ["tuple(address,address,uint160,address)[]"], + [[[USER, OTHER, 1n, WETH]]], + ), + ], + }, + { + kind: "V3_SWAP_EXACT_OUT", + step: [0x01, swapToWeth(OTHER)[1]], + }, + { + kind: "EXECUTE_SUB_PLAN", + step: [ + 0x21, + coder.encode( + ["bytes", "bytes[]"], + ["0x05", [transferShape(0x05)[1]]], + ), + ], + }, + { + kind: "ACROSS_V4_DEPOSIT_V3", + step: [0x40, coder.encode(["address"], [OTHER])], + }, + { + kind: "V4 TAKE_PORTION", + step: v4Swap([ + 0x10, + coder.encode( + ["address", "address", "uint256"], + [WETH, OTHER, 10000n], + ), + ]), + }, + ])("a swap paid to the user, then $kind", ({ step }) => { + const result = decode( + execute(swapToWeth(MSG_SENDER), step), + USER, + ROUTER_V2_1_2, + ); + expect(paidTo(result)).toEqual([UNDECODED]); + expect(detail(result, "Min. received")).toBeUndefined(); + }); +}); + +describe("paths whose recipients were untested", () => { + test("BALANCE_CHECK_ERC20 after a swap leaves who is paid as the swap says", () => { + const check = balanceCheck(WETH, ONE_WETH); + + const toUser = decode(execute(swapToWeth(MSG_SENDER), check)); + expect(paidTo(toUser)).toEqual([]); + expect(detail(toUser, "Min. received").value).toBe("1.0000 WETH"); + + const toOther = decode(execute(swapToWeth(OTHER), check)); + expect(paidTo(toOther)).toEqual([OTHER]); + expect(detail(toOther, "Min. paid out").value).toBe("1.0000 WETH"); + }); + + test("a V4 step whose swap action cannot be read still has its TAKE read", () => { + const step = (r) => v4Swap(UNREADABLE_SWAP, SETTLE_USDT, take(USDC, r)); + + const toUser = decode(execute(step(MSG_SENDER))); + expect(detail(toUser, "Steps").value).toBe( + "V4 Swap (could not be decoded)", + ); + expect(paidTo(toUser)).toEqual([]); + + expect(paidTo(decode(execute(step(OTHER))))).toEqual([OTHER]); + }); +}); + +// The DOM stub both screens are drawn into. +function makeElement(id) { + const classes = new Set(); + const el = { + id, + textContent: "", + value: "", + innerHTML: "", + style: {}, + dataset: {}, + classList: { + add: (...names) => names.forEach((n) => classes.add(n)), + remove: (...names) => names.forEach((n) => classes.delete(n)), + contains: (n) => classes.has(n), + toggle: () => false, + }, + addEventListener: () => {}, + querySelectorAll: () => [], + appendChild: () => {}, + }; + Object.defineProperty(el, "parentElement", { + get: () => node(id + "-parent"), + }); + return el; +} + +function makeDocument() { + const els = new Map(); + return { + getElementById(id) { + if (id === "debug-banner") return null; + if (!els.has(id)) els.set(id, makeElement(id)); + return els.get(id); + }, + createElement: () => makeElement("created"), + body: { prepend: () => {} }, + }; +} + +function node(id) { + return globalThis.document.getElementById(id); +} + +// A swap of 2,000 USDT for at least 1 WETH, paid to `recipient`. +const swapPaidTo = (recipient) => execute(swapToWeth(recipient)); + +// Open the approval screen for a transaction from USER sending `data` to the +// router. +async function showApproval(data) { + globalThis.document = makeDocument(); + globalThis.window = { location: { search: "" }, close: () => {} }; + globalThis.chrome.runtime = { + connect: () => ({ postMessage: () => {} }), + sendMessage: (_msg, reply) => + reply({ + type: "tx", + origin: "https://dapp.example", + isPhishingDomain: false, + approvedFrom: USER, + approvedTx: { + type: 2, + from: USER, + chainId: "0x1", + nonce: "0x7", + gasLimit: "0x30d40", + maxPriorityFeePerGas: "0x3b9aca00", + maxFeePerGas: "0x77359400", + to: ROUTER, + value: "0x0", + data, + accessList: [], + }, + }), + }; + state.networkId = "mainnet"; + state.currentView = null; + await approval.show(1); + return node("approve-tx-decoded-details").innerHTML; +} + +// The transaction the explorer reports: from USER, sending `mockSentData` to +// the router. +const TX_HASH = "0x" + "5".repeat(64); +let mockSentData = null; + +function mockExplorerAnswer(url) { + if (url.endsWith("/transactions/" + TX_HASH)) { + return { + from: { hash: USER }, + to: { hash: ROUTER }, + raw_input: mockSentData, + method: "execute", + }; + } + return {}; +} + +// Open the transaction detail screen for that transaction, sending `data`. +async function showDetail(data) { + mockSentData = data; + globalThis.document = makeDocument(); + globalThis.window = { location: { search: "" } }; + state.networkId = "mainnet"; + state.blockscoutUrl = "https://explorer.example/api/v2"; + state.viewData = {}; + state.viewStack = []; + state.currentView = null; + transactionDetail.show({ + hash: TX_HASH, + from: USER, + to: ROUTER, + value: "", + symbol: "ETH", + timestamp: 1790000000, + isError: false, + direction: "contract", + directionLabel: "Swap", + method: "execute", + chainId: "0x1", + }); + for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); + return node("tx-detail-calldata-details").innerHTML; +} + +describe("the screens judge the output against the transaction's sender", () => { + test("the approval screen, against its From address", async () => { + const toUser = await showApproval(swapPaidTo(USER)); + expect(toUser).toContain("Min. received"); + expect(toUser).not.toContain("Paid to"); + + const toOther = await showApproval(swapPaidTo(OTHER)); + expect(toOther).toContain("Paid to"); + expect(toOther).toContain(OTHER); + expect(toOther).not.toContain("Min. received"); + }); + + test("the transaction detail screen, against the sender the explorer reports", async () => { + const toUser = await showDetail(swapPaidTo(USER)); + expect(toUser).toContain("Min. received"); + expect(toUser).not.toContain("Paid to"); + + const toOther = await showDetail(swapPaidTo(OTHER)); + expect(toOther).toContain("Paid to"); + expect(toOther).not.toContain("Min. received"); + }); +}); diff --git a/tests/uniswapTokenOut.test.js b/tests/uniswapTokenOut.test.js index a3a7aa4..007ea87 100644 --- a/tests/uniswapTokenOut.test.js +++ b/tests/uniswapTokenOut.test.js @@ -53,7 +53,14 @@ function unwrapData() { } function detail(data, label, sources) { - const decoded = uniswap.decode(data, ROUTER, "0x1", sources || {}); + // Sent by the address the swap pays, so the output is the user's. + const decoded = uniswap.decode( + data, + ROUTER, + "0x1", + sources || {}, + RECIPIENT, + ); return decoded.details.find((d) => d.label === label); } diff --git a/tests/uniswapUndeterminedTokenIn.test.js b/tests/uniswapUndeterminedTokenIn.test.js index 4d078b2..bb70995 100644 --- a/tests/uniswapUndeterminedTokenIn.test.js +++ b/tests/uniswapUndeterminedTokenIn.test.js @@ -87,7 +87,7 @@ function detail(data, label) { describe("an execute() whose input currency never decoded", () => { // A V4_SWAP whose sub-action params do not decode — an action encoding // this decoder does not know — leaves every V4 token null. The - // BALANCE_CHECK still names the output, so the screen has a Min. received + // BALANCE_CHECK still names the output, so the screen has a minimum // figure and a Token Out, and previously claimed the user was paying ETH // for them. const data = () => @@ -132,7 +132,8 @@ describe("an execute() that names only an output token", () => { test("still states the output it did establish", () => { expect(detail(data(), "Token Out").value).toContain("USDT"); - expect(detail(data(), "Min. received").value).toBe("2.0000 USDT"); + // No step pays anyone, so the minimum is not said to be received. + expect(detail(data(), "Min. paid out").value).toBe("2.0000 USDT"); }); }); diff --git a/tests/uniswapUndeterminedTokenOut.test.js b/tests/uniswapUndeterminedTokenOut.test.js index 8f0a687..7192bb2 100644 --- a/tests/uniswapUndeterminedTokenOut.test.js +++ b/tests/uniswapUndeterminedTokenOut.test.js @@ -105,7 +105,7 @@ function v3ExactIn(tokenIn, tokenOut, amountIn, amountOutMin) { } function detail(data, label) { - const decoded = uniswap.decode(data, ROUTER, "0x1", {}); + const decoded = uniswap.decode(data, ROUTER, "0x1", {}, USER); expect(decoded).not.toBeNull(); return decoded.details.find((d) => d.label === label); } @@ -134,7 +134,9 @@ describe("a V4 step that states a Min. received but no output currency", () => { }); test("refuses to scale Min. received rather than assuming 18", () => { - expect(detail(data(), "Min. received").value).toBe( + // With no TAKE, no step pays the user either, so the line is not + // labelled received. + expect(detail(data(), "Min. paid out").value).toBe( unknownDecimalsAmount(MIN_OUT), ); }); diff --git a/tests/uniswapUnknownDecimals.test.js b/tests/uniswapUnknownDecimals.test.js index 0abeb22..af91a2e 100644 --- a/tests/uniswapUnknownDecimals.test.js +++ b/tests/uniswapUnknownDecimals.test.js @@ -82,7 +82,8 @@ function walletsHolding(token, decimals) { // decodeCalldata() rather than uniswap.decode() directly, because the scale // sources the screen supplies are part of what is under test. function swapDetail(data, label) { - const decoded = decodeCalldata(data, ROUTER, "0x1"); + // Sent by the address the swap pays, so the output is the user's. + const decoded = decodeCalldata(data, ROUTER, "0x1", RECIPIENT); return decoded.details.find((d) => d.label === label); }