// 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. // // Its balance line and balance check are that network's too // (https://git.eeqj.de/sneak/AutistMask/issues/558). The popup can reopen onto // a confirmation screen saved before the network was switched in another // popup, and the screen, drawn again for the new network, showed and checked // the balance Send had read on the old one. The balance read and the fee // estimate after it stay on that network when it is switched while they run. // // 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, and // a balance read answers with that network's balance. "use strict"; // The balances the stub node reports on each network, by network name: ETH // in wei and the token in its base units. The networks whose node was asked // for the fee, in order. And the signed transactions it was handed, with its // network's chain id. const mockNode = { balanceWei: {}, tokenUnits: {}, feeAskedOn: [], broadcast: [], }; jest.mock("ethers", () => { const actual = jest.requireActual("ethers"); // The selector of balanceOf(address). const BALANCE_OF = "0x70a08231"; class StubProvider { constructor(url, network) { this.network = network; } async getNetwork() { return this.network; } async getBalance() { return mockNode.balanceWei[this.network.name]; } // balanceOf() is the one contract call the screen makes before Sign // & Send. async call(tx) { if (!tx.data.startsWith(BALANCE_OF)) { throw new Error("the stub node answers only balanceOf()"); } return actual.AbiCoder.defaultAbiCoder().encode( ["uint256"], [mockNode.tokenUnits[this.network.name]], ); } async getFeeData() { mockNode.feeAskedOn.push(this.network.name); 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, JsonRpcProvider, Transaction, Wallet, parseEther, } = 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"; // A token with 6 decimal places, which the bundled list does not know. const TOKEN = "0x" + "d".repeat(40); 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.balanceWei = { mainnet: parseEther("5"), sepolia: parseEther("5"), }; mockNode.tokenUnits = { mainnet: 0n, sepolia: 0n }; mockNode.feeAskedOn = []; mockNode.broadcast = []; txStatus.showWait.mockClear(); txStatus.showError.mockClear(); signTransaction = jest.spyOn(BaseWallet.prototype, "signTransaction"); }); afterEach(() => { signTransaction.mockRestore(); }); // show() reads the balances and estimates the fee without awaiting either; // this lets both settle. async function settle() { for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); } // Show the confirmation screen for 1 of the native token on `networkId`, or // for what `send` describes instead, as Review on the Send screen does, with // its balance read and its fee estimate settled. async function review(networkId, send = {}) { await onChainSwitch(networkId); state.wallets = [ { type: "key", name: "Wallet 1", encryptedSecret, addresses: [{ address: HOLDER }], }, ]; state.selectedWallet = 0; state.selectedAddress = 0; confirmTx.init({}); confirmTx.show({ from: HOLDER, to: RECIPIENT, ensName: null, amount: "1", token: "ETH", tokenSymbol: null, tokenDecimals: null, max: false, ...send, }); await settle(); } // Switch the network to `networkId` as Settings in another popup does, then // open the popup again onto the confirmation screen it saved. async function reopenOn(networkId) { const saved = JSON.parse(JSON.stringify(state.viewData)); await onChainSwitch(networkId); elements.clear(); state.viewData = saved; confirmTx.init({}); confirmTx.restore(); await settle(); } function errors() { return el("confirm-errors").innerHTML; } function shown(id) { return el(id).style.visibility === "visible"; } function sendDisabled() { return el("btn-confirm-send").disabled; } 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([]); }); describe("a confirmation screen saved on mainnet and reopened after a switch to Sepolia", () => { test("states and checks the Sepolia balance, which covers the send", async () => { mockNode.balanceWei = { mainnet: parseEther("0.5"), sepolia: parseEther("5"), }; await review("mainnet"); expect(el("confirm-balance").textContent).toBe("0.5000 ETH"); expect(sendDisabled()).toBe(true); await reopenOn("sepolia"); expect(el("confirm-type").textContent).toBe( "Native SepoliaETH transfer", ); expect(el("confirm-balance").textContent).toBe("5.0000 SepoliaETH"); expect(errors()).toBe(""); expect(shown("confirm-balance-error")).toBe(false); expect(el("confirm-amount-fee-error").style.visibility).toBe("hidden"); expect(sendDisabled()).toBe(false); }); test("states and checks the Sepolia balance, which does not cover the send", async () => { mockNode.balanceWei = { mainnet: parseEther("5"), sepolia: parseEther("0.5"), }; await review("mainnet"); expect(el("confirm-balance").textContent).toBe("5.0000 ETH"); expect(sendDisabled()).toBe(false); await reopenOn("sepolia"); expect(el("confirm-balance").textContent).toBe("0.5000 SepoliaETH"); expect(shown("confirm-balance-error")).toBe(true); expect(el("confirm-balance-error").textContent).toBe( "Your SepoliaETH balance does not cover this amount. Please go" + " back and send a smaller amount.", ); expect(sendDisabled()).toBe(true); }); const tokenSend = { token: TOKEN, tokenSymbol: "TOK", tokenDecimals: 6, }; test("states and checks the Sepolia token balance, which covers the send", async () => { mockNode.tokenUnits = { mainnet: 0n, sepolia: 10000000n }; await review("mainnet", tokenSend); expect(el("confirm-balance").textContent).toBe("0.0000 TOK"); expect(sendDisabled()).toBe(true); await reopenOn("sepolia"); expect(el("confirm-balance").textContent).toBe("10.0000 TOK"); expect(errors()).toBe(""); expect(shown("confirm-balance-error")).toBe(false); expect(el("confirm-gas-error").style.visibility).toBe("hidden"); expect(sendDisabled()).toBe(false); }); test("states and checks the Sepolia token balance, which does not cover the send", async () => { mockNode.tokenUnits = { mainnet: 10000000n, sepolia: 500000n }; await review("mainnet", tokenSend); expect(el("confirm-balance").textContent).toBe("10.0000 TOK"); expect(sendDisabled()).toBe(false); await reopenOn("sepolia"); expect(el("confirm-balance").textContent).toBe("0.5000 TOK"); expect(shown("confirm-balance-error")).toBe(true); expect(el("confirm-balance-error").textContent).toBe( "Your TOK balance does not cover this amount. Please go back and" + " send a smaller amount.", ); expect(sendDisabled()).toBe(true); }); }); describe("the balance read", () => { test("leaves the balance line Loading... and Sign & Send disabled until it returns", async () => { let answer; const getBalance = jest .spyOn(JsonRpcProvider.prototype, "getBalance") .mockImplementationOnce(() => new Promise((r) => (answer = r))); try { await review("sepolia"); } finally { getBalance.mockRestore(); } expect(el("confirm-balance").textContent).toBe("Loading..."); expect(el("confirm-fee-amount").textContent).toBe("Estimating..."); expect(el("confirm-errors").style.visibility).toBe("hidden"); expect(shown("confirm-balance-error")).toBe(false); expect(shown("confirm-balance-read-error")).toBe(false); expect(sendDisabled()).toBe(true); answer(parseEther("5")); await settle(); expect(el("confirm-balance").textContent).toBe("5.0000 SepoliaETH"); expect(sendDisabled()).toBe(false); }); // The Chrome suite measures that nothing moves; this checks the message // is already written, only hidden, before the read returns. test("that finds the amount not covered only makes a message written before it visible", async () => { let answer; const getBalance = jest .spyOn(JsonRpcProvider.prototype, "getBalance") .mockImplementationOnce(() => new Promise((r) => (answer = r))); try { await review("sepolia"); } finally { getBalance.mockRestore(); } const message = "Your SepoliaETH balance does not cover this amount. Please go" + " back and send a smaller amount."; expect(el("confirm-balance-error").textContent).toBe(message); expect(shown("confirm-balance-error")).toBe(false); answer(parseEther("0.5")); await settle(); expect(el("confirm-balance-error").textContent).toBe(message); expect(shown("confirm-balance-error")).toBe(true); expect(errors()).toBe(""); expect(sendDisabled()).toBe(true); }); // The fee is estimated once the read returns, so a node chosen then would // be the new network's. test("still in flight when Sepolia is chosen in Settings leaves the balance and the fee on mainnet", async () => { let answer; const getBalance = jest .spyOn(JsonRpcProvider.prototype, "getBalance") .mockImplementationOnce(() => new Promise((r) => (answer = r))); try { await review("mainnet"); } finally { getBalance.mockRestore(); } expect(mockNode.feeAskedOn).toEqual([]); await onChainSwitch("sepolia"); answer(parseEther("5")); await settle(); expect(mockNode.feeAskedOn).toEqual(["mainnet"]); expect(el("confirm-balance").textContent).toBe("5.0000 ETH"); // 21000 gas at 10 gwei expected, at 20 gwei reserved. expect(el("confirm-fee-amount").textContent).toBe("~0.0002 ETH"); expect(el("confirm-fee-reserve").textContent).toBe( "up to 0.0004 ETH reserved", ); }); test("that fails states the balance as unknown, not zero, and blocks sending", async () => { const getBalance = jest .spyOn(JsonRpcProvider.prototype, "getBalance") .mockRejectedValueOnce(new Error("the node did not answer")); try { await review("sepolia"); } finally { getBalance.mockRestore(); } expect(el("confirm-balance").textContent).toBe("unknown (SepoliaETH)"); expect(shown("confirm-balance-read-error")).toBe(true); expect(shown("confirm-balance-error")).toBe(false); expect(errors()).toBe(""); expect(sendDisabled()).toBe(true); }); });