// Blockscout and Etherscan are sent none of the user's data 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. // While a setting is off, the code that would ask its service asks nothing. // // The one request made with the setting off is the check behind "Save" beside // the Blockscout URL in Settings. It asks that URL only for `/stats` and sends // none of the user's data. // // The balance refresh and the Etherscan check follow the stored setting, not // the page's copy of it. So a change another page saved applies to them at // once. An open page also takes such a change and draws its screens again. // // Only Blockscout reports the tokens the user has not tracked. While "Use // Blockscout" is off, no address keeps them and no screen lists them. // // 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). Its fetch() // stub records the same way. "use strict"; const { FetchRequest } = require("ethers"); const { bootPopup, cleanupPopup, unversionedValidProfile, ADDRESS, TOKEN_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 the stored profile has Check recipients on Etherscan on", async () => { const provider = { getCode: async () => "0x", getTransactionCount: async () => 1, getBalance: async () => 0n, }; const checkWith = async (stored) => { globalThis.chrome = { storage: makeStorageStub(stored) }; try { await getFullWarnings(RECIPIENT, provider, { chainId: "0x1" }); } finally { delete globalThis.chrome; } }; await checkWith({}); await checkWith({ autistmask: { checkRecipientsOnEtherscan: false } }); expect(asked).toEqual([]); await checkWith({ autistmask: { 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 or unticks a checkbox on the page, as the user does. async function setBox(page, id, checked) { const box = page.node(id); box.checked = checked; 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 setBox(page, "welcome-use-blockscout", true); expect(page.storage.read("autistmask").useBlockscout).toBe(true); await setBox(page, "welcome-check-recipients-on-etherscan", true); 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 setBox(page, "settings-use-blockscout", true); expect(page.storage.read("autistmask").useBlockscout).toBe(true); await setBox(page, "settings-check-recipients-on-etherscan", true); expect(page.storage.read("autistmask").checkRecipientsOnEtherscan).toBe( true, ); }); // A token the user tracks, and one Blockscout reported that the user has // not tracked. Both are written in lower case, as the balance rows' and // the dropdown's token ids are compared below. const TRACKED = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; const UNTRACKED = TOKEN_ADDRESS.toLowerCase(); const SECOND_ADDRESS = "0x3333333333333333333333333333333333333333"; function tokenRow(address, symbol) { return { address, name: symbol, symbol, decimals: 18, balance: "5.0", holders: 50000, }; } // AddressDetail of the first of two addresses, as a reopened popup draws // it. Both addresses hold the tracked token. While "Use Blockscout" is on, // both hold the untracked one too. function onAddressDetail(useBlockscout) { const profile = unversionedValidProfile({ useBlockscout, currentView: "address", viewStack: ["main"], selectedWallet: 0, selectedAddress: 0, trackedTokens: [ { address: TRACKED, name: "TRK", symbol: "TRK", decimals: 18 }, ], }); const wallet = profile.wallets[0]; wallet.nextIndex = 2; wallet.addresses.push({ address: SECOND_ADDRESS, balance: "0.0" }); for (const addr of wallet.addresses) { addr.tokenBalances = useBlockscout ? [tokenRow(TRACKED, "TRK"), tokenRow(UNTRACKED, "TST")] : [tokenRow(TRACKED, "TRK")]; } return profile; } // The token ids of the options created after the first `from` elements. // The page stub keeps no children, so this is where the options Send's // dropdown was filled with are read. function optionsSince(page, from) { return page.document.created .slice(from) .filter((el) => el.tagName === "OPTION") .map((el) => el.value.toLowerCase()); } test("unticking Use Blockscout from AddressDetail's gear takes the untracked token off AddressDetail, Send's dropdown and Home on Back", async () => { const page = await bootPopup(onAddressDetail(true)); expect(page.visibleViews()).toEqual(["address"]); expect(page.node("address-balances").innerHTML).toContain(UNTRACKED); await page.click("btn-settings"); await setBox(page, "settings-use-blockscout", false); const from = page.document.created.length; await page.click("btn-settings-back"); expect(page.visibleViews()).toEqual(["address"]); const balances = page.node("address-balances").innerHTML; expect(balances).toContain(TRACKED); expect(balances).not.toContain(UNTRACKED); expect(page.hidden("address-tokens-note")).toBe(false); expect(optionsSince(page, from)).toEqual([TRACKED]); await page.click("btn-send"); expect(page.visibleViews()).toEqual(["send"]); expect(page.hidden("send-token-dropdown")).toBe(false); expect(page.hidden("send-tokens-note")).toBe(false); await page.click("btn-send-back"); await page.click("btn-address-back"); expect(page.visibleViews()).toEqual(["main"]); const home = page.node("wallet-list").innerHTML; expect(home).toContain(TRACKED); expect(home).not.toContain(UNTRACKED); const stored = page.storage.read("autistmask"); for (const addr of stored.wallets[0].addresses) { expect( addr.tokenBalances.map((row) => row.address.toLowerCase()), ).toEqual([TRACKED]); } }); // Send's dropdown was filled under the old setting, and Back cannot draw // Send again. So Back lands on the screen under it. test.each([ ["unticking", true], ["ticking", false], ])( "%s Use Blockscout from Send's gear lands Back on AddressDetail", async (_how, before) => { const page = await bootPopup(onAddressDetail(before)); await page.click("btn-send"); expect(page.visibleViews()).toEqual(["send"]); await page.click("btn-settings"); await setBox(page, "settings-use-blockscout", !before); await page.click("btn-settings-back"); expect(page.visibleViews()).toEqual(["address"]); expect(page.hidden("address-tokens-note")).toBe(!before); }, ); test("Send says under its token dropdown that only tracked tokens are listed while Use Blockscout is off", async () => { const off = await bootPopup(onAddressDetail(false)); await off.click("btn-send"); expect(off.hidden("send-token-dropdown")).toBe(false); expect(off.hidden("send-tokens-note")).toBe(false); cleanupPopup(); const on = await bootPopup(onAddressDetail(true)); await on.click("btn-send"); expect(on.hidden("send-token-dropdown")).toBe(false); expect(on.hidden("send-tokens-note")).toBe(true); }); test("Send opened from AddressToken shows neither the dropdown nor its line", async () => { const page = await bootPopup({ ...onAddressDetail(false), currentView: "address-token", viewStack: ["main", "address"], selectedToken: "ETH", }); expect(page.visibleViews()).toEqual(["address-token"]); await page.click("btn-address-token-send"); expect(page.visibleViews()).toEqual(["send"]); expect(page.hidden("send-token-dropdown")).toBe(true); expect(page.hidden("send-token-static")).toBe(false); }); test("DeleteAddress says only tracked tokens are listed while Use Blockscout is off", async () => { for (const useBlockscout of [false, true]) { const page = await bootPopup(onAddressDetail(useBlockscout)); require("../src/popup/views/deleteAddress").show(0, 1); expect(page.visibleViews()).toEqual(["delete-address-confirm"]); expect(page.hidden("delete-address-tokens-note")).toBe( useBlockscout, ); cleanupPopup(); } }); // The token ids of the first address's balances in the stored profile. function storedTokens(page) { return page.storage .read("autistmask") .wallets[0].addresses[0].tokenBalances.map((row) => row.address.toLowerCase(), ); } // Turning the setting off dropped the untracked token. A refresh that // started while it was on still saves its balances when it returns, but // not that token. The same refresh in the background is in // tests/backgroundStateIsolation.test.js. test("a refresh that returns after Use Blockscout was turned off saves its balances without the untracked token", async () => { const page = await bootPopup(onAddressDetail(true)); const { refreshBalances: held } = require("../src/shared/balances"); let answer; held.mockImplementationOnce(async (wallets) => { await new Promise((resolve) => { answer = resolve; }); wallets[0].addresses[0].balance = "7.0"; wallets[0].addresses[0].tokenBalances = [ tokenRow(TRACKED, "TRK"), tokenRow(UNTRACKED, "TST"), ]; }); const ticking = page.tick(); await page.settle(); await page.click("btn-settings"); await setBox(page, "settings-use-blockscout", false); answer(); await ticking; expect( page.storage.read("autistmask").wallets[0].addresses[0].balance, ).toBe("7.0"); expect(storedTokens(page)).toEqual([TRACKED]); await page.click("btn-settings-back"); expect(page.node("address-balances").innerHTML).not.toContain( UNTRACKED, ); }); // Another page saves `change` over the stored profile. The browser is not // made to tell this page, so this page sees the change only by reading the // stored profile. function anotherPageSaves(page, change) { page.storage.write("autistmask", { ...page.storage.read("autistmask"), ...change, }); } test.each([ ["off", true, false], ["on", false, true], ])( "after another page turns Use Blockscout %s, this page's next refresh follows the stored setting and is saved", async (_how, before, after) => { const page = await bootPopup(onAddressDetail(before)); const { refreshBalances: held } = require("../src/shared/balances"); held.mockImplementationOnce( async (wallets, rpcUrl, blockscoutUrl, useBlockscout) => { const addr = wallets[0].addresses[0]; addr.balance = "7.0"; addr.tokenBalances = useBlockscout ? [tokenRow(TRACKED, "TRK"), tokenRow(UNTRACKED, "TST")] : [tokenRow(TRACKED, "TRK")]; }, ); anotherPageSaves(page, { useBlockscout: after }); await page.tick(); expect(held.mock.calls.at(-1)[3]).toBe(after); expect( page.storage.read("autistmask").wallets[0].addresses[0].balance, ).toBe("7.0"); expect(storedTokens(page)).toEqual( after ? [TRACKED, UNTRACKED] : [TRACKED], ); expect(page.hidden("address-tokens-note")).toBe(after); }, ); test.each([ ["off", true, false], ["on", false, true], ])( "after another page turns Check recipients on Etherscan %s, this page's next Etherscan check follows the stored setting", async (_how, before, after) => { const page = await bootPopup( onConfirmScreen({ checkRecipientsOnEtherscan: before }), ); anotherPageSaves(page, { checkRecipientsOnEtherscan: after }); const from = fetched().length; require("../src/popup/views/confirmTx").show({ token: "ETH", from: ADDRESS, to: RECIPIENT, amount: "0.1", chainId: "0x1", }); await page.settle(); // The page also takes the change and draws ConfirmTx again, which // checks once more. So each URL asked for is counted once. const asked = new Set( fetched().slice(from).filter(blockscoutOrEtherscan), ); expect([...asked]).toEqual( after ? ["https://etherscan.io/address/" + RECIPIENT] : [], ); }, ); // Another page's save of the whole profile with `change`, which the // browser announces to this page as it announces any write. async function anotherPageAnnounces(page, change) { await page.storage.local.set({ autistmask: { ...page.storage.read("autistmask"), ...change }, }); await page.settle(); } test("a page open on AddressDetail draws it again when another page turns Use Blockscout off", async () => { const page = await bootPopup(onAddressDetail(true)); expect(page.hidden("address-tokens-note")).toBe(true); await anotherPageAnnounces(page, { useBlockscout: false }); expect(page.visibleViews()).toEqual(["address"]); expect(page.hidden("address-tokens-note")).toBe(false); expect(page.node("address-balances").innerHTML).not.toContain( UNTRACKED, ); expect(storedTokens(page)).toEqual([TRACKED]); }); test("a page open on ConfirmTx draws it again when another page turns Check recipients on Etherscan off", async () => { const page = await bootPopup( onConfirmScreen({ checkRecipientsOnEtherscan: true }), ); expect(page.hidden("confirm-etherscan-note")).toBe(true); await anotherPageAnnounces(page, { checkRecipientsOnEtherscan: false }); expect(page.visibleViews()).toEqual(["confirm-tx"]); expect(page.hidden("confirm-etherscan-note")).toBe(false); }); }); 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]), dropUntrackedTokens: () => {}, })); 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); }); });