// The amount the wait and error screens show for a swap whose amount the // decoder did not read (https://git.eeqj.de/sneak/AutistMask/issues/358). // // A V4 swap action not in the deployed shape is not read, and a V4 exact-out // action is read for its currencies only, so the approval screen has no // Amount line for either. The wait and error screens then showed the // transaction's ETH value as the amount, in the swap's input token when the // calldata named one ("0.0000 WETH" for a swap of 1 WETH) and in ETH when it // did not ("0.0000 ETH"). They now say the amount could not be decoded. A swap // paid in native ETH still shows the transaction's ETH value there: that value // is the ETH the swap pays. // // Driven through the real approval screen, password and Confirm included, // against a minimal DOM stub in the shape tests/approvalNonce.test.js uses. // The vault is mocked so that no password has to be hashed. jest.mock("../src/shared/vault", () => ({ decryptWithPassword: jest.fn(), })); // The wait screen polls for a receipt, which is there once mockReceipt is set. let mockReceipt = null; jest.mock("../src/shared/balances", () => ({ getProvider: () => ({ getTransactionReceipt: async () => mockReceipt }), refreshBalances: () => {}, })); globalThis.chrome = { storage: { local: { get: async () => ({}), set: async () => {} } }, }; const { AbiCoder, Interface } = require("ethers"); const { state } = require("../src/shared/state"); const { decryptWithPassword } = require("../src/shared/vault"); const approval = require("../src/popup/views/approval"); const txStatus = require("../src/popup/views/txStatus"); // A well-known test phrase, and its first address. const PHRASE = "test test test test test test test test test test test junk"; const FROM = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; const TX_HASH = "0x" + "4".repeat(64); const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af"; const ZERO = "0x0000000000000000000000000000000000000000"; // Both in the bundled list: WETH at 18 decimals, USDC at 6. const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; const ONE_WETH = 1000000000000000000n; const coder = AbiCoder.defaultAbiCoder(); const routerIface = new Interface([ "function execute(bytes commands, bytes[] inputs, uint256 deadline)", ]); // V4 action ids and their parameters, each swapping WETH for USDC. const POOL_KEY = "tuple(address,address,uint24,int24,address)"; const WETH_USDC_POOL = [WETH, USDC, 100, 1, ZERO]; const SWAP_EXACT_IN_SINGLE = 0x06; const SWAP_EXACT_OUT_SINGLE = 0x08; const SETTLE = 0x0b; const SETTLE_ALL = 0x0c; const TAKE = 0x0e; const TAKE_ALL = 0x0f; // The struct V4Router has deployed on mainnet and Sepolia. const exactInSingle = coder.encode( [`tuple(${POOL_KEY},bool,uint128,uint128,bytes)`], [[WETH_USDC_POOL, true, ONE_WETH, 990000n, "0x"]], ); // The one v4-periphery `main` has moved to, with a `minHopPriceX36` field. const newerExactInSingle = coder.encode( [`tuple(${POOL_KEY},bool,uint128,uint128,uint256,bytes)`], [[WETH_USDC_POOL, true, ONE_WETH, 990000n, 0n, "0x"]], ); // Deployed shape: buys 990000 base units of USDC for at most 1 WETH. const exactOutSingle = coder.encode( [`tuple(${POOL_KEY},bool,uint128,uint128,bytes)`], [[WETH_USDC_POOL, true, 990000n, ONE_WETH, "0x"]], ); const settleWeth = coder.encode( ["address", "uint256", "bool"], [WETH, 0n, true], ); const takeUsdc = coder.encode( ["address", "address", "uint256"], [USDC, FROM, 0n], ); const settleAllWeth = coder.encode(["address", "uint256"], [WETH, ONE_WETH]); const takeAllUsdc = coder.encode(["address", "uint256"], [USDC, 990000n]); // The same swaps paid in native ETH, which V4 names as the zero address. const ETH_USDC_POOL = [ZERO, USDC, 500, 10, ZERO]; const ONE_ETH = 1000000000000000000n; const ethExactOutSingle = coder.encode( [`tuple(${POOL_KEY},bool,uint128,uint128,bytes)`], [[ETH_USDC_POOL, true, 990000n, ONE_ETH, "0x"]], ); const newerEthExactInSingle = coder.encode( [`tuple(${POOL_KEY},bool,uint128,uint128,uint256,bytes)`], [[ETH_USDC_POOL, true, ONE_ETH, 990000n, 0n, "0x"]], ); // The router pays it from the ETH the transaction sends. const settleEth = coder.encode( ["address", "uint256", "bool"], [ZERO, 0n, false], ); // execute() with one V4_SWAP step of these actions, given as [id, params]. function v4SwapData(actions) { const input = coder.encode( ["bytes", "bytes[]"], [new Uint8Array(actions.map((a) => a[0])), actions.map((a) => a[1])], ); return routerIface.encodeFunctionData("execute", [ "0x10", [input], 9999999999n, ]); } function makeElement(id) { const classes = new Set(); const el = { id, textContent: "", value: "", innerHTML: "", disabled: false, style: {}, dataset: {}, listeners: {}, classList: { add: (...names) => names.forEach((n) => classes.add(n)), remove: (...names) => names.forEach((n) => classes.delete(n)), contains: (n) => classes.has(n), toggle: (n, force) => { const on = force === undefined ? !classes.has(n) : force; if (on) classes.add(n); else classes.delete(n); return on; }, }, addEventListener: (name, fn) => { el.listeners[name] = el.listeners[name] || []; el.listeners[name].push(fn); }, 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, non-testnet 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); } function click(id) { return Promise.all((node(id).listeners.click || []).map((fn) => fn())); } // Open the approval screen for this calldata, sent to the router with `value` // wei (no ETH unless given), and confirm it. The background answers the signed // transaction with `response`. async function approveSwap(data, response, value = 0n) { globalThis.document = makeDocument(); globalThis.window = { location: { search: "" }, close: () => {} }; globalThis.chrome.runtime = { connect: () => ({ postMessage: () => {} }), sendMessage: (msg, reply) => { if (!reply) return; if (msg.type !== "AUTISTMASK_GET_APPROVAL") return reply(response); reply({ type: "tx", origin: "https://dapp.example", isPhishingDomain: false, approvedFrom: FROM, approvedTx: { type: 2, from: FROM, chainId: "0x1", nonce: "0x7", gasLimit: "0x30d40", maxPriorityFeePerGas: "0x3b9aca00", maxFeePerGas: "0x77359400", to: ROUTER, value: "0x" + value.toString(16), data, accessList: [], }, }); }, }; state.activeAddress = FROM; state.trackedTokens = []; state.wallets = [ { type: "hd", name: "Wallet 1", xpub: "xpub-wallet-1", encryptedSecret: "encrypted-secret-1", nextIndex: 1, addresses: [ { address: FROM, index: 0, balance: "0", tokenBalances: [] }, ], }, ]; txStatus.init({ doRefreshAndRender: () => {} }); approval.init({}); await approval.show(1); decryptWithPassword.mockResolvedValue(PHRASE); node("approve-tx-password").value = "any password"; await click("btn-approve-tx"); } afterEach(() => { txStatus.endWait(); mockReceipt = null; }); describe.each([ [ "a swap action in the deployed shape", [ [SWAP_EXACT_IN_SINGLE, exactInSingle], [SETTLE, settleWeth], [TAKE, takeUsdc], ], "1.0000 WETH", ], [ "a swap action in the newer shape, with SETTLE naming WETH", [ [SWAP_EXACT_IN_SINGLE, newerExactInSingle], [SETTLE, settleWeth], [TAKE, takeUsdc], ], "Unknown (could not be decoded)", ], [ "a swap action in the newer shape, with SETTLE_ALL and TAKE_ALL", [ [SWAP_EXACT_IN_SINGLE, newerExactInSingle], [SETTLE_ALL, settleAllWeth], [TAKE_ALL, takeAllUsdc], ], "Unknown (could not be decoded)", ], [ "an exact-out swap action, which is read for its currencies only", [ [SWAP_EXACT_OUT_SINGLE, exactOutSingle], [SETTLE, settleWeth], [TAKE, takeUsdc], ], "Unknown (could not be decoded)", ], ])("%s", (_name, actions, expected) => { test("the wait screen's amount", async () => { await approveSwap(v4SwapData(actions), { txHash: TX_HASH }); expect(node("wait-tx-summary").textContent).toBe(expected); }); test("the error screen's amount", async () => { await approveSwap(v4SwapData(actions), { error: "The transaction could not be sent.", retryable: false, }); expect(node("error-tx-summary").textContent).toBe(expected); }); // The success screen shows the approval screen's decoded lines in place // of the amount, so the ETH value is not on it either. test("the success screen has no amount line of its own", async () => { await approveSwap(v4SwapData(actions), { txHash: TX_HASH }); mockReceipt = { blockNumber: 1 }; // A later popup resumes the wait, and the receipt is there. txStatus.restoreWait(); for (let i = 0; i < 10; i++) { await new Promise((r) => setTimeout(r, 0)); } const summary = node("success-tx-summary").parentElement; expect(summary.classList.contains("hidden")).toBe(true); expect(node("success-tx-decoded").innerHTML).not.toContain("0.0000"); }); }); // A swap paid in native ETH has a Token In of "ETH (native)" and no Amount // line, and sends 1 ETH. describe.each([ [ "an exact-out swap action paid in ETH, in the deployed shape", [ [SWAP_EXACT_OUT_SINGLE, ethExactOutSingle], [SETTLE, settleEth], [TAKE, takeUsdc], ], ], [ "a swap action in the newer shape paid in ETH, with SETTLE naming ETH", [ [SWAP_EXACT_IN_SINGLE, newerEthExactInSingle], [SETTLE, settleEth], [TAKE, takeUsdc], ], ], ])("%s", (_name, actions) => { test("the wait screen shows the ETH the transaction sends", async () => { await approveSwap(v4SwapData(actions), { txHash: TX_HASH }, ONE_ETH); expect(node("wait-tx-summary").textContent).toBe("1.0000 ETH"); }); test("the error screen shows the ETH the transaction sends", async () => { await approveSwap( v4SwapData(actions), { error: "The transaction could not be sent.", retryable: false, }, ONE_ETH, ); expect(node("error-tx-summary").textContent).toBe("1.0000 ETH"); }); });