// Blockscout and Etherscan are asked nothing until the user turns them on // (https://git.eeqj.de/sneak/AutistMask/issues/668). A new profile has "Use // Blockscout" and "Check recipients on Etherscan" both off. Each request to // either service is skipped where it is made while its setting is off, and // made while it is on; the popup and the background hand the stored setting // to the code that makes the request. // // The functions that make the requests are driven directly, against a fetch() // that records every URL it is asked for. The screens are driven through the // real popup over a stored profile (tests/support/popupBoot.js), whose fetch() // stub records the same way. "use strict"; const { FetchRequest } = require("ethers"); const { bootPopup, cleanupPopup, unversionedValidProfile, ADDRESS, } = require("./support/popupBoot"); const { makeStorageStub } = require("./support/storageStub"); const { DEFAULT_STATE, normalizePersisted, } = require("../src/shared/persistedState"); const { fetchTokenBalances, refreshBalances, } = require("../src/shared/balances"); const { fetchRecentTransactions } = require("../src/shared/transactions"); const { getFullWarnings } = require("../src/shared/addressWarnings"); const { HISTORY_OFF_TEXT, escapeHtml } = require("../src/popup/views/helpers"); const RPC_URL = "https://rpc.example.invalid"; const BLOCKSCOUT = "https://eth.blockscout.com/api/v2"; const RECIPIENT = "0x2222222222222222222222222222222222222222"; const TX_HASH = "0x" + "ab".repeat(32); const ZERO_WORD = "0x" + "0".repeat(64); // USDC, from the bundled list, as Blockscout reports a holding of it. const USDC_ROW = { value: "5000000", token: { type: "ERC-20", address_hash: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", symbol: "USDC", name: "USD Coin", decimals: "6", holders_count: "50000", }, }; const realFetch = globalThis.fetch; afterEach(() => { cleanupPopup(); jest.restoreAllMocks(); }); describe("a new profile", () => { test("has both settings off", () => { expect(DEFAULT_STATE.useBlockscout).toBe(false); expect(DEFAULT_STATE.checkRecipientsOnEtherscan).toBe(false); const loaded = normalizePersisted(undefined); expect(loaded.useBlockscout).toBe(false); expect(loaded.checkRecipientsOnEtherscan).toBe(false); }); test("keeps either setting the user turned on", () => { const loaded = normalizePersisted({ useBlockscout: true, checkRecipientsOnEtherscan: true, }); expect(loaded.useBlockscout).toBe(true); expect(loaded.checkRecipientsOnEtherscan).toBe(true); }); }); describe("where each request is made", () => { // Every URL fetch() was asked for, in order. let asked; beforeEach(() => { asked = []; globalThis.fetch = jest.fn(async (url) => { asked.push(String(url)); return { ok: true, status: 200, json: async () => String(url).endsWith("/token-balances") ? [USDC_ROW] : { items: [] }, text: async () => "", }; }); }); afterEach(() => { globalThis.fetch = realFetch; }); test("Blockscout's token balances are asked for only while Use Blockscout is on", async () => { expect( await fetchTokenBalances(ADDRESS, BLOCKSCOUT, false, []), ).toEqual([]); expect(asked).toEqual([]); const rows = await fetchTokenBalances(ADDRESS, BLOCKSCOUT, true, []); expect(rows.map((row) => row.symbol)).toEqual(["USDC"]); expect(asked).toEqual([ BLOCKSCOUT + "/addresses/" + ADDRESS + "/token-balances", ]); }); test("the transaction history is asked for only while Use Blockscout is on", async () => { expect( await fetchRecentTransactions(ADDRESS, BLOCKSCOUT, false, "0x1"), ).toEqual([]); expect(asked).toEqual([]); await fetchRecentTransactions(ADDRESS, BLOCKSCOUT, true, "0x1"); expect(asked.sort()).toEqual([ BLOCKSCOUT + "/addresses/" + ADDRESS + "/token-transfers?type=ERC-20", BLOCKSCOUT + "/addresses/" + ADDRESS + "/transactions", ]); }); test("the Etherscan label check is made only while Check recipients on Etherscan is on", async () => { const provider = { getCode: async () => "0x", getTransactionCount: async () => 1, getBalance: async () => 0n, }; await getFullWarnings(RECIPIENT, provider, { chainId: "0x1" }); await getFullWarnings(RECIPIENT, provider, { chainId: "0x1", checkRecipientsOnEtherscan: false, }); expect(asked).toEqual([]); await getFullWarnings(RECIPIENT, provider, { chainId: "0x1", checkRecipientsOnEtherscan: true, }); expect(asked).toEqual(["https://etherscan.io/address/" + RECIPIENT]); }); describe("a balance refresh", () => { // The RPC endpoint answers every balance with zero, and every other // call, the ENS reverse lookup's, with no resolver. beforeEach(() => { FetchRequest.registerGetUrl(async (req) => { const body = JSON.parse(Buffer.from(req.body).toString("utf8")); const reply = (r) => ({ jsonrpc: "2.0", id: r.id, result: r.method === "eth_getBalance" ? "0x0" : ZERO_WORD, }); const answer = Array.isArray(body) ? body.map(reply) : reply(body); return { statusCode: 200, statusMessage: "OK", headers: { "content-type": "application/json" }, body: Buffer.from(JSON.stringify(answer)), }; }); }); afterEach(() => { FetchRequest.registerGetUrl(FetchRequest.createGetUrlFunc()); }); // An address that was refreshed while Use Blockscout was on, and holds // the USDC Blockscout reported then. function addressWithUsdc() { return { address: ADDRESS, tokenBalances: [ { address: USDC_ROW.token.address_hash, name: "USD Coin", symbol: "USDC", decimals: 6, balance: "5.0", holders: 50000, }, ], }; } async function refresh(addr, useBlockscout) { await refreshBalances( [{ addresses: [addr] }], RPC_URL, BLOCKSCOUT, useBlockscout, [], "mainnet", ); } test("asks Blockscout nothing while Use Blockscout is off, and then lists no token the user has not tracked", async () => { const addr = addressWithUsdc(); await refresh(addr, false); expect(asked).toEqual([]); expect(addr.tokenBalances).toEqual([]); }); test("asks Blockscout for the token balances while Use Blockscout is on", async () => { const addr = addressWithUsdc(); await refresh(addr, true); expect(asked).toEqual([ BLOCKSCOUT + "/addresses/" + ADDRESS + "/token-balances", ]); expect(addr.tokenBalances.map((row) => row.symbol)).toEqual([ "USDC", ]); }); }); }); describe("the popup", () => { // Every URL the booted page handed to fetch(). function fetched() { return globalThis.fetch.mock.calls.map((call) => String(call[0])); } function blockscoutOrEtherscan(url) { return url.includes("blockscout") || url.includes("etherscan"); } beforeEach(() => { // The screens below log their failed RPC reads; the stub node in // popupBoot.js answers none. jest.spyOn(console, "error").mockImplementation(() => {}); }); test("hands its balance refresh and Home's history the stored Use Blockscout setting", async () => { await bootPopup(unversionedValidProfile()); const { refreshBalances: popupRefresh, } = require("../src/shared/balances"); const { fetchRecentTransactions: popupHistory, } = require("../src/shared/transactions"); expect(popupRefresh.mock.calls[0][3]).toBe(false); expect(popupHistory.mock.calls[0][2]).toBe(false); cleanupPopup(); await bootPopup(unversionedValidProfile({ useBlockscout: true })); const { refreshBalances: onRefresh, } = require("../src/shared/balances"); const { fetchRecentTransactions: onHistory, } = require("../src/shared/transactions"); expect(onRefresh.mock.calls[0][3]).toBe(true); expect(onHistory.mock.calls[0][2]).toBe(true); }); test("Home says why there is no history and only tracked tokens while Use Blockscout is off", async () => { const page = await bootPopup(unversionedValidProfile()); expect(page.node("home-tx-list").innerHTML).toContain( escapeHtml(HISTORY_OFF_TEXT), ); expect(page.hidden("home-tokens-note")).toBe(false); cleanupPopup(); const on = await bootPopup( unversionedValidProfile({ useBlockscout: true }), ); expect(on.node("home-tx-list").innerHTML).toContain( "No transactions found.", ); expect(on.hidden("home-tokens-note")).toBe(true); }); // The transaction screen, as a reopened popup draws it. function onTransactionScreen(extra) { return unversionedValidProfile({ currentView: "transaction", viewStack: ["main"], selectedWallet: 0, selectedAddress: 0, viewData: { tx: { hash: TX_HASH, from: ADDRESS, to: RECIPIENT, value: "1.0000", exactValue: "1.0", symbol: "ETH", timestamp: 1790000000, isError: false, direction: "sent", directionLabel: "Sent", contractAddress: null, chainId: "0x1", }, }, ...extra, }); } test("TransactionDetail asks Blockscout for the transaction only while Use Blockscout is on", async () => { const page = await bootPopup(onTransactionScreen()); expect(page.visibleViews()).toEqual(["transaction"]); expect(fetched().filter(blockscoutOrEtherscan)).toEqual([]); expect(page.hidden("tx-detail-blockscout-note")).toBe(false); cleanupPopup(); const on = await bootPopup( onTransactionScreen({ useBlockscout: true }), ); expect(fetched().filter(blockscoutOrEtherscan)).toEqual([ BLOCKSCOUT + "/transactions/" + TX_HASH, ]); expect(on.hidden("tx-detail-blockscout-note")).toBe(true); }); // The confirmation screen for a send of 0.1 ETH to RECIPIENT, as a // reopened popup draws it. function onConfirmScreen(extra) { return unversionedValidProfile({ currentView: "confirm-tx", viewStack: ["main"], selectedWallet: 0, selectedAddress: 0, viewData: { pendingTx: { token: "ETH", from: ADDRESS, to: RECIPIENT, amount: "0.1", chainId: "0x1", }, }, ...extra, }); } test("ConfirmTx asks Etherscan about the recipient only while Check recipients on Etherscan is on", async () => { const page = await bootPopup(onConfirmScreen()); expect(page.visibleViews()).toEqual(["confirm-tx"]); expect(fetched().filter(blockscoutOrEtherscan)).toEqual([]); expect(page.hidden("confirm-etherscan-note")).toBe(false); expect(page.hidden("confirm-etherscan-warning")).toBe(true); cleanupPopup(); const on = await bootPopup( onConfirmScreen({ checkRecipientsOnEtherscan: true }), ); expect(fetched().filter(blockscoutOrEtherscan)).toEqual([ "https://etherscan.io/address/" + RECIPIENT, ]); expect(on.hidden("confirm-etherscan-note")).toBe(true); expect(on.hidden("confirm-etherscan-warning")).toBe(false); }); // Ticks a checkbox on the page, as the user does. async function tick(page, id) { const box = page.node(id); box.checked = true; for (const fn of box.listeners.change) await fn(); await page.settle(); } test("Welcome offers both settings unticked, and ticking one there stores it", async () => { const page = await bootPopup(undefined); expect(page.visibleViews()).toEqual(["welcome"]); expect(page.node("welcome-use-blockscout").checked).toBe(false); expect(page.node("welcome-check-recipients-on-etherscan").checked).toBe( false, ); await tick(page, "welcome-use-blockscout"); expect(page.storage.read("autistmask").useBlockscout).toBe(true); await tick(page, "welcome-check-recipients-on-etherscan"); expect(page.storage.read("autistmask").checkRecipientsOnEtherscan).toBe( true, ); }); test("Settings shows both settings unticked, and ticking one there stores it", async () => { const page = await bootPopup(unversionedValidProfile()); await page.click("btn-settings"); expect(page.node("settings-use-blockscout").checked).toBe(false); expect( page.node("settings-check-recipients-on-etherscan").checked, ).toBe(false); await tick(page, "settings-use-blockscout"); expect(page.storage.read("autistmask").useBlockscout).toBe(true); await tick(page, "settings-check-recipients-on-etherscan"); expect(page.storage.read("autistmask").checkRecipientsOnEtherscan).toBe( true, ); }); }); describe("the background", () => { afterEach(() => { delete globalThis.chrome; jest.dontMock("../src/shared/balances"); }); // Starts the background over `profile`, fires its balance alarm, and // answers with the Use Blockscout setting its refresh was handed. async function settingTheRefreshIsHanded(profile) { jest.resetModules(); let handed; const called = new Promise((resolve) => { handed = resolve; }); jest.doMock("../src/shared/balances", () => ({ getProvider: () => ({}), refreshBalances: async (...args) => handed(args[3]), })); let onAlarm = null; globalThis.chrome = { storage: makeStorageStub({ autistmask: profile }), alarms: { create: () => {}, get: async () => undefined, clear: async () => false, onAlarm: { addListener: (fn) => { onAlarm = fn; }, }, }, runtime: { onMessage: { addListener: () => {} }, onConnect: { addListener: () => {} }, onInstalled: { addListener: () => {} }, onStartup: { addListener: () => {} }, getURL: (p) => "chrome-extension://test/" + p, lastError: null, }, windows: { onRemoved: { addListener: () => {} } }, tabs: { query: () => {}, sendMessage: () => {} }, action: { setPopup: () => {} }, }; require("../src/background/index"); const { BALANCE_REFRESH_ALARM } = require("../src/shared/alarms"); onAlarm({ name: BALANCE_REFRESH_ALARM }); return called; } test("hands its balance refresh the stored Use Blockscout setting", async () => { const profile = { ...unversionedValidProfile(), lastBalanceRefresh: 0 }; expect(await settingTheRefreshIsHanded(profile)).toBe(false); expect( await settingTheRefreshIsHanded({ ...profile, useBlockscout: true, }), ).toBe(true); }); });