diff --git a/README.md b/README.md index 709333c..9a15122 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` when +the output is paid to another address, 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. @@ -2279,7 +2280,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 for a swap whose output is paid to an + address other than the From address, each address it is paid to (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 @@ -2914,7 +2917,24 @@ 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`, `UNWRAP_WETH` and V4 `TAKE` + names the address it pays, and `TAKE_ALL` pays the transaction's sender. The + router reads two of those addresses as placeholders: `address(1)` is the + transaction's sender, and `address(2)` is the router itself, which holds what + it is paid until a later step pays it out. The output is paid to the address + of the step that sets the output side (for a V4 step, of every `TAKE` and + `TAKE_ALL` of its output currency), or, when no step sets it, of the + `UNWRAP_WETH` that makes `Token Out` ETH. While the router holds it, the next + `SWEEP` of the output token, or `UNWRAP_WETH` of a WETH output, pays it to + that step's address. A `BALANCE_CHECK_ERC20` step pays nothing and leaves the + address as it was. An output nothing pays out is paid to the router. When the + output is paid to any address other than the transaction's sender (the From + address on TxApproval, the sender the explorer reports on TransactionDetail), + a `Paid to` line after `Token Out` names each address it is paid to, the + sender's included, and `Min. received` reads `Min. paid out`. A swap paid to + the sender, directly or through the router, has neither. The decoder does not + read `TRANSFER`, `PAY_PORTION` or V4 `TAKE_PORTION`, each of which pays an + amount or a share to an address of its own. ### 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..17e6b67 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -8,6 +8,7 @@ const { resolveTokenSymbol, unknownDecimalsAmount, } = require("./approvalAmount"); +const { sameAddress } = require("./approvalVerify"); const { NETWORKS } = require("./networks"); const coder = AbiCoder.defaultAbiCoder(); @@ -179,6 +180,33 @@ const WETH_ADDRESSES = [ "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", ]; +function isWeth(address) { + return present(address) && WETH_ADDRESSES.includes(address.toLowerCase()); +} + +// 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 the output until a +// later step pays it out. The swap commands, SWEEP 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 and TAKE_ALL: https://github.com/Uniswap/v4-periphery/blob/444c526b77d804590f0d7bc5a481af5a3277c952/src/V4Router.sol +const MSG_SENDER = "0x0000000000000000000000000000000000000001"; +const ADDRESS_THIS = "0x0000000000000000000000000000000000000002"; + +// The address a command's `recipient` pays, read as the router reads it, for a +// transaction from `sender` to the router at `router`. +function recipientAddress(recipient, sender, router) { + if (sameAddress(recipient, MSG_SENDER)) return sender; + if (sameAddress(recipient, ADDRESS_THIS)) return router; + return recipient; +} + // `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 +293,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 +314,7 @@ function decodeV2SwapExactOut(input) { input, ); return { + recipient: d[0], amountOut: d[1], amountInMax: d[2], tokenIn: d[3][0], @@ -316,6 +346,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,6 +357,30 @@ function decodeV3SwapExactIn(input) { } } +// 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 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; + } +} + // Decode WRAP_ETH (command 0x0b) input bytes. // ABI: (address recipient, uint256 amount) function decodeWrapEth(input) { @@ -500,6 +555,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. +// +// `outputRecipients` is the recipient of every TAKE and TAKE_ALL of Token +// Out, as written in the calldata (see recipientAddress()), TAKE_ALL's as +// MSG_SENDER. It is empty when none takes Token Out. function decodeV4Swap(input) { try { const d = coder.decode(["bytes", "bytes[]"], input); @@ -508,6 +567,8 @@ function decodeV4Swap(input) { let settleToken = null; let takeToken = null; + // Every TAKE and TAKE_ALL, as { currency, recipient }. + const takes = []; // The step's swap actions that could be read, as decodeV4SwapAction() // reads them. const swaps = []; @@ -537,10 +598,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 || @@ -558,6 +621,11 @@ function decodeV4Swap(input) { } } + const recipientsOf = (currency) => + takes + .filter((t) => sameAddress(t.currency, currency)) + .map((t) => t.recipient); + // Rule 3. if (swapUndecoded) { return { @@ -565,6 +633,7 @@ function decodeV4Swap(input) { tokenOut: takeToken, amountIn: null, amountOutMin: null, + outputRecipients: recipientsOf(takeToken), swapUndecoded, hasSwapAction: true, }; @@ -604,6 +673,7 @@ function decodeV4Swap(input) { tokenOut, amountIn, amountOutMin, + outputRecipients: recipientsOf(tokenOut), swapUndecoded, hasSwapAction: swaps.length > 0, }; @@ -612,14 +682,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 { @@ -636,6 +706,9 @@ function decode(data, toAddress, chainId, sources) { let outputToken = null; let minOutput = null; let outputEstablished = false; + // The addresses the output side is paid to, each read as the router + // reads it (see recipientAddress()). Empty when no step read says. + let outputPaidTo = []; let hasUnwrapWeth = false; let hasV2ExactOut = false; let swapUndecoded = false; @@ -671,12 +744,24 @@ function decode(data, toAddress, chainId, sources) { if (!present(token) && !present(amount)) return; setInput(token, amount); }; - const setOutput = (token, amount) => { + // The step that sets the output side also says who it is paid to. + const setOutput = (token, amount, addresses) => { if (!present(token) && !present(amount)) return; outputToken = present(token) ? token : null; minOutput = present(amount) ? amount : null; + outputPaidTo = addresses; outputEstablished = true; }; + const paidTo = (recipients) => + recipients.map((r) => recipientAddress(r, fromAddress, toAddress)); + // SWEEP and UNWRAP_WETH pay out the router's whole balance, so an + // output the router holds goes to their recipient. + const payOutFromRouter = (recipient) => { + const [address] = paidTo([recipient]); + outputPaidTo = outputPaidTo.map((a) => + sameAddress(a, toAddress) ? address : a, + ); + }; for (let i = 0; i < commandsBytes.length; i++) { const cmdId = commandsBytes[i] & commandTypeMask; @@ -708,8 +793,9 @@ function decode(data, toAddress, chainId, sources) { // 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, so who the output is paid to stays. if (b && !(b.minBalance === 0n && present(minOutput))) { - setOutput(b.token, b.minBalance); + setOutput(b.token, b.minBalance, outputPaidTo); } } @@ -720,7 +806,11 @@ function decode(data, toAddress, chainId, sources) { // Always update output: in multi-step swaps (V3 → V4), // the last swap step determines the final output token // and minimum received amount. - setOutput(s.tokenOut, s.amountOutMin); + setOutput( + s.tokenOut, + s.amountOutMin, + paidTo([s.recipient]), + ); } } @@ -728,7 +818,11 @@ function decode(data, toAddress, chainId, sources) { const s = decodeV2SwapExactIn(inputs[i]); if (s) { setInputOnce(s.tokenIn, s.amountIn); - setOutput(s.tokenOut, s.amountOutMin); + setOutput( + s.tokenOut, + s.amountOutMin, + paidTo([s.recipient]), + ); } } @@ -738,7 +832,18 @@ function decode(data, toAddress, chainId, sources) { const s = decodeV2SwapExactOut(inputs[i]); if (s) { setInputOnce(s.tokenIn, s.amountInMax); - setOutput(s.tokenOut, s.amountOut); + setOutput( + s.tokenOut, + s.amountOut, + paidTo([s.recipient]), + ); + } + } + + if (cmdId === 0x04) { + const s = decodeSweep(inputs[i]); + if (s && sameAddress(s.token, outputToken)) { + payOutFromRouter(s.recipient); } } @@ -775,6 +880,7 @@ function decode(data, toAddress, chainId, sources) { } outputToken = v4.tokenOut; minOutput = v4.amountOutMin; + outputPaidTo = paidTo(v4.outputRecipients); outputEstablished = true; } else if (v4) { // A step with no swap action sets only the sides its @@ -782,12 +888,24 @@ function decode(data, toAddress, chainId, sources) { // setInputOnce() and setOutput() leave alone a side // whose two halves are both unstated. setInputOnce(v4.tokenIn, v4.amountIn); - setOutput(v4.tokenOut, v4.amountOutMin); + setOutput( + v4.tokenOut, + v4.amountOutMin, + paidTo(v4.outputRecipients), + ); } } if (cmdId === 0x0c) { hasUnwrapWeth = true; + // It pays out the output when that is WETH, and when no + // step has set the output side: see the ETH output below. + const u = decodeUnwrapWeth(inputs[i]); + if (u && !outputEstablished) { + outputPaidTo = paidTo([u.recipient]); + } else if (u && isWeth(outputToken)) { + payOutFromRouter(u.recipient); + } } } catch { // Skip commands we can't decode @@ -804,6 +922,7 @@ function decode(data, toAddress, chainId, sources) { inputAmount = null; outputToken = null; minOutput = null; + outputPaidTo = []; outputEstablished = true; } @@ -816,12 +935,9 @@ function decode(data, toAddress, chainId, sources) { // even when it set it unknown. Any other output keeps its own token // and figure: a swap that buys USDC and then unwraps the WETH it did // not spend receives USDC. - const outputIsWeth = - present(outputToken) && - WETH_ADDRESSES.includes(outputToken.toLowerCase()); const inInfo = tokenInfo(inputToken, sources); const outInfo = - hasUnwrapWeth && (outputIsWeth || !outputEstablished) + hasUnwrapWeth && (isWeth(outputToken) || !outputEstablished) ? { symbol: "ETH", decimals: 18, address: null } : tokenInfo(outputToken, sources); @@ -936,6 +1052,22 @@ function decode(data, toAddress, chainId, sources) { }); } + // When any address the output is paid to is not the user's, each one + // is named, the user's own included, and the output is not said to be + // received. An output the router still holds is paid to the router. + const paidElsewhere = outputPaidTo.some( + (a) => !sameAddress(a, fromAddress), + ); + if (paidElsewhere) { + const named = outputPaidTo.filter( + (a, i) => + outputPaidTo.findIndex((b) => sameAddress(a, b)) === i, + ); + for (const address of named) { + details.push({ label: "Paid to", value: address, address }); + } + } + 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 +1076,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: paidElsewhere ? "Min. paid out" : "Min. received", 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..0ffbb88 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"); @@ -373,6 +379,8 @@ describe("uniswap decoder", () => { v4SwapCalldata(id, deployed), ROUTER_ADDR, "0x1", + {}, + USER_ADDR, ); expect(detail(result, "Steps").value).toBe("V4 Swap"); }, @@ -389,6 +397,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 +429,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 +456,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 +492,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 +526,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 +554,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 +574,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 +601,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 +633,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 +662,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 +705,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 +732,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 +765,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 +784,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 +809,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 +832,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 +844,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,7 +867,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(); // UNWRAP_WETH means output is native ETH expect(result.name).toBe("Swap USDT \u2192 ETH"); @@ -843,7 +883,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 minOut = result.details.find((d) => d.label === "Min. received"); @@ -863,7 +903,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 +983,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 +1077,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 +1098,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 +1122,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 +1137,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 +1212,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 +1246,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 +1286,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 +1331,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 +1360,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 +1397,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"); @@ -1532,7 +1590,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("Swap USDT → USDC"); expect(detail(result, "Token In").address).toBe(USDT_ADDR); expect(detail(result, "Token Out").address).toBe(USDC_ADDR); @@ -1584,7 +1648,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 +1713,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 +1769,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 +1792,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 +1826,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 +1850,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 +1881,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 +1902,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"); @@ -1851,7 +1921,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("USDT"); @@ -1871,7 +1941,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 +1961,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..b58af3a --- /dev/null +++ b/tests/uniswapRecipient.test.js @@ -0,0 +1,522 @@ +// The address a swap's output is paid to +// (https://git.eeqj.de/sneak/AutistMask/issues/594). +// +// Every swap command, SWEEP, UNWRAP_WETH and V4 TAKE names the address it +// pays, and 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 caller and address(2) as the router itself, +// which holds the output 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"; +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; + +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) { + return [ + command, + coder.encode( + ["address", "uint256", "uint256", "address[]", "bool"], + [recipient, amount, limit, path, true], + ), + ]; +} + +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])]; +} + +// 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"]], + ), +]; +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) { + 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.address.toLowerCase()); +} + +// 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", +]; + +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(v3SwapExactIn(r, USDT_2000, ONE_WETH, USDT, WETH)), + 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 of the output token 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("is paid to the router when nothing pays it out", () => { + // A SWEEP of another token leaves it there. + for (const data of [ + execute(toRouter), + execute(toRouter, sweep(USDT, MSG_SENDER)), + ]) { + const result = decode(data); + expect(labels(result)).toEqual(PAID_ELSEWHERE); + expect(paidTo(result)).toEqual([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([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 = v3SwapExactIn( + ADDRESS_THIS, + USDT_2000, + ONE_WETH, + USDT, + WETH, + ); + + 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("with no step setting the output, UNWRAP_WETH's recipient is paid the ETH", () => { + const wrap = wrapEth(ADDRESS_THIS, ONE_WETH); + + const toUser = decode(execute(wrap, unwrapWeth(MSG_SENDER))); + expect(detail(toUser, "Token Out").value).toBe("ETH"); + expect(paidTo(toUser)).toEqual([]); + + const toOther = decode(execute(wrap, unwrapWeth(OTHER))); + expect(detail(toOther, "Token Out").value).toBe("ETH"); + 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]); + }); +}); + +describe("several V4 takes of the output", () => { + test("TAKE_ALL pays the user; a TAKE to another address beside it names both", () => { + 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, USER.toLowerCase()]); + expect(detail(split, "Min. received")).toBeUndefined(); + expect(detail(split, "Min. paid out").value).toBe("1990.0000 USDC"); + }); +}); + +// 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(v3SwapExactIn(recipient, USDT_2000, ONE_WETH, USDT, WETH)); + +// 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/uniswapUndeterminedTokenOut.test.js b/tests/uniswapUndeterminedTokenOut.test.js index 8f0a687..be39b5a 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); } 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); }