// A transfer is sent only on the network its confirmation screen was drawn for // (https://git.eeqj.de/sneak/AutistMask/issues/524). // // Settings is reachable from the confirmation screen, and Back from it only // unhides the screen, still labelled for the network it was drawn for. Sign & // Send used to take the network at the moment of the click, so 1 SepoliaETH // reviewed on Sepolia went out as 1 ETH on mainnet once mainnet was chosen in // Settings in between. // // Driven through the real confirmation screen, signer and chain switch, with // only the node and the DOM stubbed. The stub node is on the network its // provider was built for, so a broadcast records the network it went to. "use strict"; // The signed transactions the stub node was handed, with its network's chain id. const mockNode = { broadcast: [] }; jest.mock("ethers", () => { const actual = jest.requireActual("ethers"); class StubProvider { constructor(url, network) { this.network = network; } async getNetwork() { return this.network; } async getFeeData() { return { maxFeePerGas: 20000000000n, maxPriorityFeePerGas: 1000000000n, gasPrice: 10000000000n, }; } async estimateGas() { return 21000n; } async getCode() { return "0x"; } async getTransactionCount() { return 1; } async lookupAddress() { return null; } async broadcastTransaction(signed) { mockNode.broadcast.push({ chainId: this.network.chainId, signed }); return { hash: "0x" + "ab".repeat(32) }; } } return { ...actual, JsonRpcProvider: StubProvider }; }); jest.mock("../src/shared/log", () => ({ log: { debugf: () => {}, infof: () => {}, warnf: () => {}, errorf: () => {}, }, debugFetch: jest.fn(async () => ({ ok: true, status: 200, json: async () => ({ items: [] }), })), urlOrigin: () => "", setRuntimeDebug: () => {}, isDebug: () => false, })); // The wait screen polls for a receipt; these tests stop at the broadcast. jest.mock("../src/popup/views/txStatus", () => ({ showWait: jest.fn(), showError: jest.fn(), })); global.fetch = jest.fn(() => { throw new Error("tests must not perform network requests"); }); const { makeStorageStub } = require("./support/storageStub"); global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } }; // A stub DOM: every id resolves to a recording element. const elements = new Map(); function makeEl(id) { const handlers = new Map(); return { id, textContent: "", innerHTML: "", value: "", disabled: false, style: {}, dataset: {}, classList: { add() {}, remove() {}, toggle() {}, contains: () => false, }, handlers, addEventListener(name, fn) { handlers.set(name, fn); }, querySelectorAll: () => [], querySelector: () => null, remove() {}, focus() {}, }; } global.document = { getElementById(id) { if (!elements.has(id)) elements.set(id, makeEl(id)); return elements.get(id); }, createElement: (tag) => makeEl(tag), body: { prepend() {}, appendChild() {} }, addEventListener() {}, }; global.navigator = { clipboard: { writeText() {} } }; const { BaseWallet, Transaction, Wallet } = require("ethers"); const { encryptWithPassword } = require("../src/shared/vault"); const { state, loadState } = require("../src/shared/state"); const { onChainSwitch } = require("../src/shared/chainSwitch"); const { NETWORKS } = require("../src/shared/networks"); const txStatus = require("../src/popup/views/txStatus"); const confirmTx = require("../src/popup/views/confirmTx"); const PRIVATE_KEY = "0x" + "11".repeat(32); const HOLDER = new Wallet(PRIVATE_KEY).address; const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe"; const PASSWORD = "correct horse battery staple"; function el(id) { return global.document.getElementById(id); } let encryptedSecret; let signTransaction; beforeAll(async () => { await loadState(); encryptedSecret = await encryptWithPassword(PRIVATE_KEY, PASSWORD); }); beforeEach(() => { elements.clear(); mockNode.broadcast = []; txStatus.showWait.mockClear(); txStatus.showError.mockClear(); signTransaction = jest.spyOn(BaseWallet.prototype, "signTransaction"); }); afterEach(() => { signTransaction.mockRestore(); }); // Show the confirmation screen for 1 of the native token on `networkId`, as // Review on the Send screen does, with its fee estimate settled. async function review(networkId) { await onChainSwitch(networkId); state.wallets = [ { type: "key", name: "Wallet 1", encryptedSecret, addresses: [{ address: HOLDER, balance: "5.0" }], }, ]; state.selectedWallet = 0; state.selectedAddress = 0; confirmTx.init({}); confirmTx.show({ from: HOLDER, to: RECIPIENT, ensName: null, amount: "1", token: "ETH", balance: "5.0", tokenSymbol: null, tokenBalance: null, tokenDecimals: null, max: false, }); for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); } async function signAndSend() { el("confirm-tx-password").value = PASSWORD; await el("btn-confirm-send").handlers.get("click")(); } test("a transfer reviewed on Sepolia and sent goes out on Sepolia", async () => { await review("sepolia"); expect(el("confirm-type").textContent).toBe("Native SepoliaETH transfer"); expect(el("btn-confirm-send").disabled).toBe(false); await signAndSend(); expect(mockNode.broadcast).toHaveLength(1); expect(mockNode.broadcast[0].chainId).toBe(11155111n); expect(Transaction.from(mockNode.broadcast[0].signed).chainId).toBe( 11155111n, ); expect(txStatus.showWait).toHaveBeenCalledTimes(1); }); describe.each([ ["sepolia", "mainnet", "SepoliaETH"], ["mainnet", "sepolia", "ETH"], ])( "a transfer reviewed on %s, with %s chosen in Settings before Sign & Send", (reviewedOn, switchedTo, symbol) => { test("is neither signed nor sent, and the screen says why", async () => { await review(reviewedOn); expect(el("confirm-type").textContent).toBe( "Native " + symbol + " transfer", ); // Settings' network select, then Back, which only unhides the // screen as it was drawn. await onChainSwitch(switchedTo); await signAndSend(); expect(signTransaction).not.toHaveBeenCalled(); expect(mockNode.broadcast).toEqual([]); expect(txStatus.showWait).not.toHaveBeenCalled(); expect(txStatus.showError).not.toHaveBeenCalled(); expect(el("confirm-errors").style.visibility).toBe("visible"); expect(el("confirm-errors").innerHTML).toContain( "The active network changed after this transaction was" + " reviewed, so it was not sent. Please go back and review" + " it again.", ); }); }, ); // The check in the click handler tells the user; the send itself carries the // chain id the screen was drawn for, so a signer on another network's node // cannot sign it even if the network changes after that check. test("the send is refused by a signer on another network's node", async () => { const { getProvider } = require("../src/shared/balances"); const signer = new Wallet(PRIVATE_KEY).connect( getProvider("https://rpc.example.invalid", "mainnet"), ); await expect( confirmTx.populateVerifyAndSend(signer, { token: "ETH", to: RECIPIENT, amount: "1", chainId: NETWORKS.sepolia.chainId, }), ).rejects.toThrow("transaction chainId mismatch"); expect(signTransaction).not.toHaveBeenCalled(); expect(mockNode.broadcast).toEqual([]); });