// Which transactions are shown as a swap through the Uniswap Universal Router // (https://git.eeqj.de/sneak/AutistMask/issues/520). // // The decoder used to recognise the router's execute() call by its selector // alone, so the same calldata sent to an ordinary address was shown as a swap // through the router, with that address as its Protocol. It is now decoded // only when the transaction is sent to a Universal Router deployment on the // network it is for; anywhere else it is left to the raw data. // // The transaction detail screen and the approval screen are driven against a // minimal DOM stub in the shape tests/tokenTransferDetail.test.js uses, the // approval screen opened the way tests/swapStatusAmount.test.js opens it. const { AbiCoder, Interface, getAddress, 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 { state } = require("../src/shared/state"); const approval = require("../src/popup/views/approval"); const { decodeCalldata } = approval; const transactionDetail = require("../src/popup/views/transactionDetail"); const MAINNET = "0x1"; const SEPOLIA = "0xaa36a7"; // From Uniswap's deployment files: one router on Ethereum Mainnet only, one on // Sepolia only, and one at the same address on both. const MAINNET_ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af"; const SEPOLIA_ROUTER = "0x3a9d48ab9751398bbfa63ad67599bb04e4bdf98b"; const ROUTER_ON_BOTH = "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD"; // The address with no code from the issue. const ORDINARY = "0x0b0050Da68435b3EcBC236c40F16dB2070bCD79A"; const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe"; const SEPOLIA_WETH = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14"; const TOKEN = "0xE2E0000000000000000000000000000000000E2e"; const HUNDREDTH_ETH = 10000000000000000n; const coder = AbiCoder.defaultAbiCoder(); const routerIface = new Interface([ "function execute(bytes commands, bytes[] inputs, uint256 deadline)", ]); // The calldata from the issue: wrap 0.01 ETH, then a V3 swap of it into a // token. const SWAP_DATA = routerIface.encodeFunctionData("execute", [ solidityPacked(["uint8", "uint8"], [0x0b, 0x00]), [ coder.encode(["address", "uint256"], [RECIPIENT, HUNDREDTH_ETH]), coder.encode( ["address", "uint256", "uint256", "bytes", "bool"], [ RECIPIENT, HUNDREDTH_ETH, 1n, solidityPacked( ["address", "uint24", "address"], [SEPOLIA_WETH, 3000, TOKEN], ), false, ], ), ], 9999999999n, ]); beforeEach(() => { state.trackedTokens = []; state.wallets = []; }); describe("execute() calldata on the approval screen's decoder", () => { test("sent to a router on the transaction's network, it is a swap", () => { const onMainnet = decodeCalldata(SWAP_DATA, MAINNET_ROUTER, MAINNET); expect(onMainnet.description).toBe("Swap via Uniswap Universal Router"); expect(onMainnet.details[0]).toMatchObject({ label: "Protocol", address: MAINNET_ROUTER, }); expect( decodeCalldata(SWAP_DATA, SEPOLIA_ROUTER, SEPOLIA).description, ).toBe("Swap via Uniswap Universal Router"); }); test("sent to an ordinary address, it is not decoded", () => { expect(decodeCalldata(SWAP_DATA, ORDINARY, MAINNET)).toBeNull(); expect(decodeCalldata(SWAP_DATA, ORDINARY, SEPOLIA)).toBeNull(); }); test("sent to a router of the other network only, it is not decoded", () => { expect(decodeCalldata(SWAP_DATA, SEPOLIA_ROUTER, MAINNET)).toBeNull(); expect(decodeCalldata(SWAP_DATA, MAINNET_ROUTER, SEPOLIA)).toBeNull(); }); test("a router deployed at the same address on both networks is one on each", () => { expect( decodeCalldata(SWAP_DATA, ROUTER_ON_BOTH, MAINNET), ).not.toBeNull(); expect( decodeCalldata(SWAP_DATA, ROUTER_ON_BOTH, SEPOLIA), ).not.toBeNull(); }); test("the router address matches in any letter case", () => { const upper = "0x" + MAINNET_ROUTER.slice(2).toUpperCase(); for (const address of [getAddress(MAINNET_ROUTER), upper]) { expect(decodeCalldata(SWAP_DATA, address, MAINNET)).not.toBeNull(); } }); }); // The transaction the explorer reports: SWAP_DATA, sent to `mockSentTo`. const TX_HASH = "0x" + "5".repeat(64); let mockSentTo = null; function mockExplorerAnswer(url) { if (url.endsWith("/transactions/" + TX_HASH)) { return { to: { hash: mockSentTo }, raw_input: SWAP_DATA, method: "execute", }; } return {}; } 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: () => {}, }; // Views reach for .parentElement to hide whole sections. Object.defineProperty(el, "parentElement", { get: () => node(id + "-parent"), }); return el; } function makeDocument() { const els = new Map(); return { getElementById(id) { // The debug banner is created on demand by helpers.js; absent // is the state a non-debug popup is in. 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); } // Open the transaction detail screen with Sepolia active, for the // transaction the explorer reports sent to `sentTo`, and wait for its // decoded details. async function showDetailSentTo(sentTo) { mockSentTo = sentTo; globalThis.document = makeDocument(); globalThis.window = { location: { search: "" } }; state.networkId = "sepolia"; state.blockscoutUrl = "https://explorer.example/api/v2"; state.viewData = {}; state.viewStack = []; state.currentView = null; transactionDetail.show({ hash: TX_HASH, from: RECIPIENT, to: sentTo, value: "", symbol: "SepoliaETH", timestamp: 1790000000, isError: false, direction: "contract", directionLabel: "Swap", method: "execute", chainId: SEPOLIA, }); for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); } describe("execute() calldata on the transaction detail screen", () => { test("sent to a router on the active network, it is a swap", async () => { await showDetailSentTo(SEPOLIA_ROUTER); expect(node("tx-detail-calldata-details").innerHTML).toContain( "Swap via Uniswap Universal Router", ); }); test("sent to an ordinary address, it is not decoded", async () => { await showDetailSentTo(ORDINARY); expect(node("tx-detail-calldata-action").textContent).toBe("execute"); expect(node("tx-detail-calldata-details").innerHTML).toBe(""); expect(node("tx-detail-rawdata").innerHTML).toContain(SWAP_DATA); }); test("sent to a router of the other network only, it is not decoded", async () => { await showDetailSentTo(MAINNET_ROUTER); expect(node("tx-detail-calldata-action").textContent).toBe("execute"); expect(node("tx-detail-calldata-details").innerHTML).toBe(""); expect(node("tx-detail-rawdata").innerHTML).toContain(SWAP_DATA); }); }); // Open the approval screen with Ethereum Mainnet active, for a site's Sepolia // transaction that sends SWAP_DATA to `sentTo`. async function showApprovalSentTo(sentTo) { 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: RECIPIENT, approvedTx: { type: 2, from: RECIPIENT, chainId: SEPOLIA, nonce: "0x7", gasLimit: "0x30d40", maxPriorityFeePerGas: "0x3b9aca00", maxFeePerGas: "0x77359400", to: sentTo, value: "0x" + HUNDREDTH_ETH.toString(16), data: SWAP_DATA, accessList: [], }, }), }; state.networkId = "mainnet"; state.currentView = null; await approval.show(1); } // The router is on Sepolia's list only, so the swap is shown only if the // screen decodes for the transaction's network: on Ethereum Mainnet, the // active network, that address is not a router. describe("execute() calldata on the approval screen, for a Sepolia transaction while Ethereum Mainnet is active", () => { test("sent to a router on Sepolia, it is a swap", async () => { await showApprovalSentTo(SEPOLIA_ROUTER); expect(node("approve-tx-decoded").classList.contains("hidden")).toBe( false, ); expect(node("approve-tx-decoded-details").innerHTML).toContain( "Swap via Uniswap Universal Router", ); }); test("sent to an ordinary address, it is shown as raw data", async () => { await showApprovalSentTo(ORDINARY); expect(node("approve-tx-decoded").classList.contains("hidden")).toBe( true, ); expect(node("approve-tx-decoded-details").innerHTML).toBe(""); expect(node("approve-tx-data").textContent).toBe(SWAP_DATA); expect( node("approve-tx-data-section").classList.contains("hidden"), ).toBe(false); }); });