// 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. // // The same holds when the popup was closed after the switch and reopened, and // Back drew the screen again from the transaction it saved // (https://git.eeqj.de/sneak/AutistMask/issues/586): the screen was drawn for // the new network, and Sign & Send sent the reviewed transfer there. Drawn // again, the screen describes the transaction entirely on the network it was // reviewed for, because choosing that network again in Settings and pressing // Back only unhides it, and Sign & Send then sends from it. // // Its balance line and balance check are that network's balance, read from // that network's node each time the screen is drawn // (https://git.eeqj.de/sneak/AutistMask/issues/558), not the balance Send // handed over. The fee estimate after the read stays on that network when // the network is switched while the read runs. // // Driven through the real confirmation screen, signer and chain switch, with // only the node, fetch 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 questions the stub node was asked for the balance, the fee and the // recipient checks, by method, network name and RPC URL, in order. The // balances it reports on each network, by network name: ETH in wei and the // token in its base units. The signed transactions it was handed, with its // network's chain id and RPC URL. And whether it quotes fees as a network // with no type-2 pricing does. const mockNode = { asked: [], balanceWei: {}, tokenUnits: {}, broadcast: [], legacyFees: false, }; jest.mock("ethers", () => { const actual = jest.requireActual("ethers"); // The selector of balanceOf(address). const BALANCE_OF = "0x70a08231"; class StubProvider { constructor(url, network) { this.url = url; this.network = network; } record(method) { mockNode.asked.push({ method, network: this.network.name, url: this.url, }); } async getNetwork() { return this.network; } async getBalance() { this.record("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()"); } this.record("balanceOf"); return actual.AbiCoder.defaultAbiCoder().encode( ["uint256"], [mockNode.tokenUnits[this.network.name]], ); } async getFeeData() { this.record("getFeeData"); if (mockNode.legacyFees) { return { maxFeePerGas: null, maxPriorityFeePerGas: null, gasPrice: 10000000000n, }; } return { maxFeePerGas: 20000000000n, maxPriorityFeePerGas: 1000000000n, gasPrice: 10000000000n, }; } async estimateGas() { return 21000n; } async getCode() { this.record("getCode"); return "0x"; } async getTransactionCount() { this.record("getTransactionCount"); return 1; } async lookupAddress() { return null; } async broadcastTransaction(signed) { mockNode.broadcast.push({ chainId: this.network.chainId, url: this.url, 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(), })); // The Etherscan label check is its only caller here: the explorer pages it // asks for are recorded, and nothing is fetched. 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, saveState } = require("../src/shared/state"); const { onChainSwitch } = require("../src/shared/chainSwitch"); const { NETWORKS } = require("../src/shared/networks"); const { prices } = require("../src/shared/prices"); const { goBack, pushCurrentView, setBackRenderer, showView, } = require("../src/popup/views/helpers"); const { makeBackRenderer, resetRenderedViews, } = require("../src/popup/viewRouter"); 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 TOKEN = "0x7070707070707070707070707070707070707070"; 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.asked = []; mockNode.balanceWei = { mainnet: parseEther("5"), sepolia: parseEther("5"), }; mockNode.tokenUnits = { mainnet: 0n, sepolia: 0n }; mockNode.broadcast = []; mockNode.legacyFees = false; global.fetch.mockClear(); 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`, or // for the transfer `changes` makes of it, as Review on the Send screen does, // with its balance read and its fee estimate settled. async function review(networkId, changes = {}) { await onChainSwitch(networkId); await reviewOnActiveNetwork(changes); } // review() on the network already active, without switching to it again. async function reviewOnActiveNetwork(changes = {}) { 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, ...changes, }); await settle(); } // The user's own node for a network, set in Settings. function ownNode(networkId) { return "https://" + networkId + ".node.example.invalid"; } // After review() on `reviewedOn`: the user's own node set for it, the Settings // gear, `switchedTo` chosen there, the popup closed and reopened onto // Settings, and Back, which draws the confirmation screen again from the // transaction it saved. The reopened page holds an ETH price of $2,000 and a // USDC price of $1 whichever network is active, so each USD value shows which // network it was priced for. async function switchReopenAndGoBack(reviewedOn, switchedTo) { state.rpcUrl = ownNode(reviewedOn); pushCurrentView(); showView("settings"); await onChainSwitch(switchedTo); await loadState(); elements.clear(); resetRenderedViews(); setBackRenderer(makeBackRenderer(state, { confirmTx })); confirmTx.init({}); prices.ETH = 2000; prices.USDC = 1; mockNode.asked = []; global.fetch.mockClear(); goBack(); await settle(); } // The USD value `text` as the screen appends it for `networkId`: nothing on a // testnet, which has no USD values. function usdOn(networkId, text) { return NETWORKS[networkId].isTestnet ? "" : " (" + text + ")"; } // The screen for the transfer review() shows by default, described on // `networkId`: in its native currency, priced by it, linking to its explorer. function expectNativeTransferOn(networkId) { const { nativeCurrency: symbol, explorerUrl } = NETWORKS[networkId]; expect(el("confirm-type").textContent).toBe( "Native " + symbol + " transfer", ); expect(el("confirm-amount").textContent).toBe( "1 " + symbol + usdOn(networkId, "$2,000.00"), ); expect(el("confirm-balance").textContent).toBe( "5.0000 " + symbol + usdOn(networkId, "$10,000.00"), ); expect(el("confirm-fee-amount").textContent).toBe( "~0.0002 " + symbol + usdOn(networkId, "$0.42"), ); expect(el("confirm-fee-reserve").textContent).toBe( "up to 0.0004 " + symbol + " reserved", ); expect(el("confirm-gas-error").textContent).toBe( "You do not have enough " + symbol + " to pay the network fee for this transfer. Please add " + symbol + " to this address and try again.", ); expect(el("confirm-recipient-warning").textContent).toContain( " holds no " + symbol + ". ", ); expect(el("confirm-contract-warning").textContent).toContain( " Sending " + symbol + " or tokens ", ); expect(el("confirm-from").innerHTML).toContain( `href="${explorerUrl}/address/${HOLDER}"`, ); expect(el("confirm-to").innerHTML).toContain( `href="${explorerUrl}/address/${RECIPIENT}"`, ); } // show() reads the balance and then estimates the fee, and checks the // recipient, without awaiting any of them; this lets all of them finish. async function settle() { for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); } 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); }); // Saving an RPC endpoint in Settings (src/popup/views/settings.js) sets only // state.rpcUrl: the endpoint remembered for the active network keeps its old // value until the next switch away from it. test("a transfer reviewed after the active network's RPC endpoint was changed in Settings asks the new endpoint", async () => { const newNode = "https://new.node.example.invalid"; await onChainSwitch("sepolia"); state.rpcUrl = newNode; await saveState(); await reviewOnActiveNetwork(); expect(mockNode.asked).toEqual([ { method: "getBalance", network: "sepolia", url: newNode }, { method: "getCode", network: "sepolia", url: newNode }, { method: "getTransactionCount", network: "sepolia", url: newNode }, { method: "getFeeData", network: "sepolia", url: newNode }, ]); }); 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.", ); }); }, ); describe.each([ ["sepolia", "mainnet"], ["mainnet", "sepolia"], ])( "a transfer reviewed on %s, with %s chosen in Settings, the popup reopened and Back pressed", (reviewedOn, switchedTo) => { const { nativeCurrency: symbol, explorerUrl } = NETWORKS[reviewedOn]; test("is described on the network it was reviewed for, and neither signed nor sent", async () => { await review(reviewedOn); await switchReopenAndGoBack(reviewedOn, switchedTo); expect(state.currentView).toBe("confirm-tx"); expectNativeTransferOn(reviewedOn); expect(mockNode.asked).toEqual([ { method: "getBalance", network: reviewedOn, url: ownNode(reviewedOn), }, { method: "getCode", network: reviewedOn, url: ownNode(reviewedOn), }, { method: "getTransactionCount", network: reviewedOn, url: ownNode(reviewedOn), }, { method: "getFeeData", network: reviewedOn, url: ownNode(reviewedOn), }, ]); expect(global.fetch.mock.calls.map(([url]) => url)).toEqual([ explorerUrl + "/address/" + RECIPIENT, ]); expect(el("btn-confirm-send").disabled).toBe(false); await signAndSend(); expect(signTransaction).not.toHaveBeenCalled(); expect(mockNode.broadcast).toEqual([]); expect(txStatus.showWait).not.toHaveBeenCalled(); expect(txStatus.showError).not.toHaveBeenCalled(); expect(el("confirm-errors").innerHTML).toContain( "The active network changed after this transaction was" + " reviewed, so it was not sent.", ); }); test("is sent on that network, as described, once it is chosen in Settings again and Back pressed", async () => { await review(reviewedOn); await switchReopenAndGoBack(reviewedOn, switchedTo); // The Settings gear, the reviewed network chosen, and Back, which // only unhides the screen as it was drawn. pushCurrentView(); showView("settings"); await onChainSwitch(reviewedOn); goBack(); expect(state.currentView).toBe("confirm-tx"); expectNativeTransferOn(reviewedOn); await signAndSend(); const chainId = BigInt(NETWORKS[reviewedOn].chainId); expect(mockNode.broadcast).toHaveLength(1); expect(mockNode.broadcast[0].chainId).toBe(chainId); expect(mockNode.broadcast[0].url).toBe(ownNode(reviewedOn)); expect(Transaction.from(mockNode.broadcast[0].signed).chainId).toBe( chainId, ); expect(txStatus.showWait).toHaveBeenCalledTimes(1); }); test("for more than the balance, says so in that network's native currency", async () => { await review(reviewedOn, { amount: "10" }); await switchReopenAndGoBack(reviewedOn, switchedTo); expect(shown("confirm-balance-error")).toBe(true); expect(el("confirm-balance-error").textContent).toBe( "Your " + symbol + " balance does not cover this amount. Please go back and" + " send a smaller amount.", ); }); test("states and checks that network's balance as it is then, which no longer covers the send", async () => { await review(reviewedOn); expect(sendDisabled()).toBe(false); mockNode.balanceWei = { [reviewedOn]: parseEther("0.5"), [switchedTo]: parseEther("5"), }; await switchReopenAndGoBack(reviewedOn, switchedTo); expect(el("confirm-balance").textContent).toBe( "0.5000 " + symbol + usdOn(reviewedOn, "$1,000.00"), ); expect(shown("confirm-balance-error")).toBe(true); expect(sendDisabled()).toBe(true); }); test("states and checks that network's balance as it is then, which now covers the send, and refuses to send it", async () => { mockNode.balanceWei[reviewedOn] = parseEther("0.5"); await review(reviewedOn); expect(sendDisabled()).toBe(true); mockNode.balanceWei = { [reviewedOn]: parseEther("5"), [switchedTo]: parseEther("0.5"), }; await switchReopenAndGoBack(reviewedOn, switchedTo); expect(el("confirm-balance").textContent).toBe( "5.0000 " + symbol + usdOn(reviewedOn, "$10,000.00"), ); expect(errors()).toBe(""); expect(shown("confirm-balance-error")).toBe(false); expect(shown("confirm-amount-fee-error")).toBe(false); expect(sendDisabled()).toBe(false); await signAndSend(); expect(signTransaction).not.toHaveBeenCalled(); expect(mockNode.broadcast).toEqual([]); expect(errors()).toContain( "The active network changed after this transaction was" + " reviewed, so it was not sent.", ); }); const tokenSend = { token: TOKEN, tokenSymbol: "TOK", tokenDecimals: 6, }; test("of a token, states and checks that network's token balance as it is then, which no longer covers the send", async () => { mockNode.tokenUnits[reviewedOn] = 10000000n; await review(reviewedOn, tokenSend); expect(sendDisabled()).toBe(false); mockNode.tokenUnits = { [reviewedOn]: 500000n, [switchedTo]: 10000000n, }; await switchReopenAndGoBack(reviewedOn, switchedTo); 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); }); test("of a token, states and checks that network's token balance as it is then, which now covers the send, and refuses to send it", async () => { await review(reviewedOn, tokenSend); expect(sendDisabled()).toBe(true); mockNode.tokenUnits = { [reviewedOn]: 10000000n, [switchedTo]: 0n, }; await switchReopenAndGoBack(reviewedOn, switchedTo); expect(el("confirm-balance").textContent).toBe("10.0000 TOK"); expect(errors()).toBe(""); expect(shown("confirm-balance-error")).toBe(false); expect(shown("confirm-gas-error")).toBe(false); expect(sendDisabled()).toBe(false); await signAndSend(); expect(signTransaction).not.toHaveBeenCalled(); expect(mockNode.broadcast).toEqual([]); expect(errors()).toContain( "The active network changed after this transaction was" + " reviewed, so it was not sent.", ); }); test("with a fee quoted by a node with no type-2 pricing, shows the fee in that network's native currency and price", async () => { await review(reviewedOn); mockNode.legacyFees = true; await switchReopenAndGoBack(reviewedOn, switchedTo); expect(el("confirm-fee-amount").textContent).toBe( "0.0002 " + symbol + usdOn(reviewedOn, "$0.42"), ); }); test("of an ERC-20 token, links the token contract to that network's explorer and prices the token by that network", async () => { mockNode.tokenUnits[reviewedOn] = 50000000n; await review(reviewedOn, { token: TOKEN, tokenSymbol: "USDC", tokenDecimals: 6, amount: "10", }); await switchReopenAndGoBack(reviewedOn, switchedTo); expect(el("confirm-type").textContent).toBe( "ERC-20 token transfer (USDC)", ); expect(el("confirm-token-contract").innerHTML).toContain( `href="${explorerUrl}/address/${TOKEN}"`, ); expect(el("confirm-amount").textContent).toBe( "10 USDC" + usdOn(reviewedOn, "$10.00"), ); expect(el("confirm-balance").textContent).toBe( "50.0000 USDC" + usdOn(reviewedOn, "$50.00"), ); }); }, ); // 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("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, after the switch, and still // asks the node of the network the screen was drawn for. 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(); } const feeAskedOn = () => mockNode.asked .filter((q) => q.method === "getFeeData") .map((q) => q.network); expect(feeAskedOn()).toEqual([]); await onChainSwitch("sepolia"); answer(parseEther("5")); await settle(); expect(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); }); });