// Home, AddressDetail and AddressToken drop a transaction history answer that // lands after a network switch. A dropped answer draws no rows and saves no // fraud contracts (https://git.eeqj.de/sneak/AutistMask/issues/657). Home's list // shows nothing of the previous network once it is drawn after a switch // (https://git.eeqj.de/sneak/AutistMask/issues/718). // // Most tests hold the history request open, switch the network or what the // screen shows, and then let the answer land. The views run against a minimal // DOM stub, as in tests/transactionListCancelled.test.js, and over // tests/support/storageStub.js. const { makeStorageStub } = require("./support/storageStub"); const { STATE_SCHEMA_VERSION } = require("../src/shared/stateSchema"); jest.mock("../src/shared/transactions", () => ({ ...jest.requireActual("../src/shared/transactions"), fetchRecentTransactions: jest.fn(async () => []), })); jest.mock("../src/shared/ens", () => ({ resolveEnsNames: jest.fn(async () => new Map()), })); const { fetchRecentTransactions } = require("../src/shared/transactions"); const { resolveEnsNames } = require("../src/shared/ens"); const { state, loadState } = require("../src/shared/state"); const { onChainSwitch } = require("../src/shared/chainSwitch"); const { applyChainSwitchFields } = require("../src/shared/chainSwitchFields"); const { storedNetworkIs } = require("../src/shared/storedSettings"); const home = require("../src/popup/views/home"); const addressDetail = require("../src/popup/views/addressDetail"); const addressToken = require("../src/popup/views/addressToken"); const ADDRESS = "0x1111111111111111111111111111111111111111"; const OTHER_ADDRESS = "0x2222222222222222222222222222222222222222"; const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; // A contract that sends a token claiming USDT's symbol. Spotting the spoof // adds this contract to the fraud contracts. const SPOOF_CONTRACT = "0x3333333333333333333333333333333333333333"; const TOKEN = "0x6B175474E89094C44Da98b954EedeAC495271d0F"; // A profile on Ethereum Mainnet with two addresses, "Use Blockscout" on and no // fraud contracts. function profile() { return { schemaVersion: STATE_SCHEMA_VERSION, hasWallet: true, wallets: [ { type: "hd", name: "Main", xpub: "xpub-main", encryptedSecret: "encrypted-secret", nextIndex: 2, addresses: [ { address: ADDRESS, balance: "1.0", tokenBalances: [] }, { address: OTHER_ADDRESS, balance: "0.0", tokenBalances: [], }, ], }, ], activeAddress: ADDRESS, networkId: "mainnet", rpcUrl: "https://ethereum-rpc.publicnode.com", blockscoutUrl: "https://eth.blockscout.com/api/v2", useBlockscout: true, fraudContracts: [], }; } // What Blockscout answers for ADDRESS on Ethereum Mainnet: // // - a transfer of ETH from ADDRESS; // - a transfer of the fake USDT from SPOOF_CONTRACT. function history() { return [ { hash: "0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84", blockNumber: 2, from: ADDRESS, to: RECIPIENT, value: "1.0000", exactValue: "1.0", rawAmount: "1000000000000000000", rawUnit: "wei", symbol: "ETH", timestamp: 1790000000, isError: false, directionLabel: "Sent", direction: "sent", contractAddress: null, chainId: "0x1", }, { hash: "0x0d6a6e0a0d1b7d4c3b2a19087f6e5d4c3b2a19087f6e5d4c3b2a19087f6e5d4c", blockNumber: 1, from: SPOOF_CONTRACT, to: ADDRESS, value: "100.0000", exactValue: "100.0", rawAmount: "100000000", rawUnit: "base units", symbol: "USDT", timestamp: 1790000000, isError: false, directionLabel: "Received", direction: "received", contractAddress: SPOOF_CONTRACT, chainId: "0x1", }, ]; } // The next history request waits until the returned function is called, and // then answers with `answer`. function holdHistory(answer = history()) { let land; fetchRecentTransactions.mockImplementationOnce( () => new Promise((resolve) => { land = () => resolve(answer); }), ); return () => land(); } // The next history request waits until the returned function is called, and // then fails. function holdFailure() { let fail; fetchRecentTransactions.mockImplementationOnce( () => new Promise((resolve, reject) => { fail = () => reject(new Error("Blockscout is down")); }), ); return () => fail(); } // Another write holds the lock until the returned function is called. Every // save waits until then. function holdLock() { let release; const held = navigator.locks.request( "autistmask", () => new Promise((resolve) => { release = resolve; }), ); return async () => { release(); await held; }; } // Every read of storage waits until the returned function is called. Each read // then answers with what storage holds at that moment, the oldest read first. function holdReads() { const read = storage.local.get; const waiting = []; storage.local.get = (key) => new Promise((resolve) => { waiting.push(() => resolve(read(key))); }); return () => { storage.local.get = read; for (const answer of waiting) answer(); }; } function makeElement(id) { const el = { id, textContent: "", value: "", innerHTML: "", style: {}, dataset: {}, classList: { add: () => {}, remove: () => {}, contains: () => false, toggle: () => false, }, addEventListener: () => {}, querySelectorAll: () => [], appendChild: () => {}, }; // Views reach for .parentElement to hide whole sections. Object.defineProperty(el, "parentElement", { get: () => node(id + "-parent"), }); return el; } function makeDocument() { const els = new Map(); return { getElementById(id) { // The debug banner is created on demand by helpers.js; absent // is the state a non-debug, non-testnet popup is in. if (id === "debug-banner") return null; if (!els.has(id)) els.set(id, makeElement(id)); return els.get(id); }, createElement: () => makeElement("created"), addEventListener: () => {}, body: { prepend: () => {} }, }; } function node(id) { return globalThis.document.getElementById(id); } // Lets every promise that is not held open settle. function settle() { return new Promise((resolve) => setTimeout(resolve, 0)); } let storage; beforeEach(async () => { storage = makeStorageStub({ autistmask: profile() }); globalThis.chrome = { storage: { local: storage.local, onChanged: storage.onChanged }, }; globalThis.document = makeDocument(); globalThis.window = { location: { search: "" } }; await loadState(); state.viewStack = []; state.selectedWallet = 0; state.selectedAddress = 0; state.selectedToken = "ETH"; const ctx = { pageClosed: new AbortController().signal }; addressDetail.init(ctx); addressToken.init(ctx); }); afterEach(() => { delete globalThis.chrome; delete globalThis.document; delete globalThis.window; }); const HOME = { name: "Home", listId: "home-tx-list", open: () => home.render({ pageClosed: new AbortController().signal }), }; const ADDRESS_DETAIL = { name: "AddressDetail", listId: "tx-list", open: () => addressDetail.show(), }; const ADDRESS_TOKEN = { name: "AddressToken", listId: "address-token-tx-list", open: () => addressToken.show(), }; // Three ways a test switches the network: // // - made in Settings: the page and storage have both switched; // - saved elsewhere: storage has switched and the page has not; // - not yet stored: the page has switched and storage has not. const SWITCHES = [ { how: "made in Settings", to: (networkId) => onChainSwitch(networkId), }, { // Another page or the background stored the switch, and this page has // not followed it yet. how: "saved elsewhere", to: async (networkId) => { const record = storage.read("autistmask"); applyChainSwitchFields(record, networkId); storage.write("autistmask", record); }, }, { // The switch's own save has changed the page and has not yet written // storage. how: "not yet stored", to: async (networkId) => { applyChainSwitchFields(state, networkId); }, }, ]; // The list still shows `before`, and neither the page nor storage holds a // fraud contract. function expectNothingAdded(screen, before) { expect(node(screen.listId).innerHTML).toBe(before); expect(state.fraudContracts).toEqual([]); expect(storage.read("autistmask").fraudContracts).toEqual([]); } describe.each([HOME, ADDRESS_DETAIL, ADDRESS_TOKEN])("$name", (screen) => { test("an answer that lands on the same network is drawn and saved", async () => { const land = holdHistory(); screen.open(); land(); await settle(); expect(node(screen.listId).innerHTML).toContain("tx-row"); expect(state.fraudContracts).toEqual([SPOOF_CONTRACT]); expect(storage.read("autistmask").fraudContracts).toEqual([ SPOOF_CONTRACT, ]); }); test.each(SWITCHES)( "an answer that lands after a switch $how adds nothing", async ({ to }) => { const land = holdHistory(); screen.open(); await settle(); const before = node(screen.listId).innerHTML; await to("sepolia"); land(); await settle(); expectNothingAdded(screen, before); }, ); // The answer is the transfer of ETH alone, which adds no fraud contract. // Nothing is saved, so only the check before the save can drop the answer. test.each(SWITCHES)( "an answer without a fraud contract that lands after a switch $how adds nothing", async ({ to }) => { const land = holdHistory(history().slice(0, 1)); screen.open(); await settle(); const before = node(screen.listId).innerHTML; await to("sepolia"); land(); await settle(); expectNothingAdded(screen, before); }, ); test.each(SWITCHES)( "a request that fails after a switch $how shows no message", async ({ to }) => { const fail = holdFailure(); screen.open(); await settle(); const before = node(screen.listId).innerHTML; await to("sepolia"); fail(); await settle(); expect(node(screen.listId).innerHTML).toBe(before); }, ); // The user picks Sepolia in Settings while another write holds the lock. // The answer lands, and its save waits behind the switch. test("an answer whose save waits behind a switch made in Settings adds nothing", async () => { const land = holdHistory(); screen.open(); const before = node(screen.listId).innerHTML; const releaseLock = holdLock(); const switching = onChainSwitch("sepolia"); land(); await settle(); await releaseLock(); await switching; await settle(); expectNothingAdded(screen, before); }); // The answer lands and its save waits for the lock. Another page or the // background then stores a switch. test("an answer whose save waits while a switch is saved elsewhere adds nothing", async () => { const land = holdHistory(); screen.open(); const before = node(screen.listId).innerHTML; const releaseLock = holdLock(); land(); await settle(); const record = storage.read("autistmask"); applyChainSwitchFields(record, "sepolia"); storage.write("autistmask", record); await releaseLock(); await settle(); expectNothingAdded(screen, before); }); // The answer lands and its save waits for the lock. The page then switches, // and storage has not. test("an answer whose save waits while the page switches adds nothing", async () => { const land = holdHistory(); screen.open(); const before = node(screen.listId).innerHTML; const releaseLock = holdLock(); land(); await settle(); applyChainSwitchFields(state, "sepolia"); await releaseLock(); await settle(); expectNothingAdded(screen, before); }); test("a request that fails after a switch leaves the new network's rows", async () => { const fail = holdFailure(); screen.open(); await onChainSwitch("sepolia"); fetchRecentTransactions.mockImplementationOnce(async () => history()); screen.open(); await settle(); const drawn = node(screen.listId).innerHTML; fail(); await settle(); expect(drawn).toContain("tx-row"); expect(node(screen.listId).innerHTML).toBe(drawn); }); // The user picks Sepolia in Settings, and the switch reads storage. The // answer lands during that read, so the screen's own read of storage still // finds Ethereum Mainnet. The switch changes the page before the screen's // read ends. The answer is the transfer of ETH alone, so nothing is saved, // and only a check after the screen's read can drop the answer. test("an answer that lands during the first read of a switch made in Settings adds nothing", async () => { const land = holdHistory(history().slice(0, 1)); screen.open(); await settle(); const before = node(screen.listId).innerHTML; const answerReads = holdReads(); const switching = onChainSwitch("sepolia"); await settle(); land(); await settle(); answerReads(); await switching; await settle(); expectNothingAdded(screen, before); }); test("a request that fails during the first read of a switch made in Settings shows no message", async () => { const fail = holdFailure(); screen.open(); await settle(); const before = node(screen.listId).innerHTML; const answerReads = holdReads(); const switching = onChainSwitch("sepolia"); await settle(); fail(); await settle(); answerReads(); await switching; await settle(); expect(node(screen.listId).innerHTML).toBe(before); }); }); describe("Home's list", () => { test.each([ { what: "rows", hold: holdHistory, shown: "tx-row" }, { what: "failure message", hold: holdFailure, shown: "Failed to load transactions.", }, ])( "shows the previous network's $what neither before nor after the new network's answer lands", async ({ hold, shown }) => { const answer = hold(); HOME.open(); answer(); await settle(); expect(node(HOME.listId).innerHTML).toContain(shown); await onChainSwitch("sepolia"); const land = holdHistory([]); HOME.open(); await settle(); expect(node(HOME.listId).innerHTML).toContain("Loading..."); land(); await settle(); expect(node(HOME.listId).innerHTML).toContain( "No transactions found.", ); }, ); // The user leaves Ethereum Mainnet with a request waiting, comes back, and // that request lands. The list then holds Ethereum Mainnet's answer, so the // next draw on Sepolia must replace it. test.each([ { what: "rows", hold: holdHistory }, { what: "failure message", hold: holdFailure }, ])( "shows no $what of an older request that lands after a switch back", async ({ hold }) => { const answer = hold(); HOME.open(); await onChainSwitch("sepolia"); holdHistory(); HOME.open(); await onChainSwitch("mainnet"); answer(); await settle(); await onChainSwitch("sepolia"); holdHistory(); HOME.open(); await settle(); const list = node(HOME.listId).innerHTML; expect(list).toContain("Loading..."); expect(list).not.toContain("tx-row"); expect(list).not.toContain("Failed to load transactions."); }, ); // Each balance refresh draws Home again, so the rows must not blink. test("keeps its rows while a redraw on the same network waits for the answer", async () => { fetchRecentTransactions.mockImplementationOnce(async () => history()); HOME.open(); await settle(); const drawn = node(HOME.listId).innerHTML; holdHistory(); HOME.open(); await settle(); expect(drawn).toContain("tx-row"); expect(node(HOME.listId).innerHTML).toBe(drawn); }); }); describe.each([ADDRESS_DETAIL, ADDRESS_TOKEN])("$name", (screen) => { test("an answer that lands once the screen shows another address adds nothing", async () => { const land = holdHistory(); screen.open(); state.selectedAddress = 1; screen.open(); await settle(); const before = node(screen.listId).innerHTML; land(); await settle(); expectNothingAdded(screen, before); }); test("a request that fails once the screen shows another address shows no message", async () => { const fail = holdFailure(); screen.open(); state.selectedAddress = 1; screen.open(); await settle(); const before = node(screen.listId).innerHTML; fail(); await settle(); expect(node(screen.listId).innerHTML).toBe(before); }); // The answer is the transfer of ETH alone, which adds no fraud contract. // Nothing is saved, so only the check after the name lookup can drop the // answer. test("an answer whose name lookup lands after a switch adds nothing", async () => { let landNames; resolveEnsNames.mockImplementationOnce( () => new Promise((resolve) => { landNames = () => resolve(new Map()); }), ); fetchRecentTransactions.mockImplementationOnce(async () => history().slice(0, 1), ); screen.open(); await settle(); const before = node(screen.listId).innerHTML; await onChainSwitch("sepolia"); landNames(); await settle(); expectNothingAdded(screen, before); }); }); test("AddressToken: an answer that lands once the screen shows another token adds nothing", async () => { const land = holdHistory(); addressToken.show(); state.selectedToken = TOKEN; addressToken.show(); await settle(); const before = node(ADDRESS_TOKEN.listId).innerHTML; land(); await settle(); expectNothingAdded(ADDRESS_TOKEN, before); }); test("AddressToken: a request that fails once the screen shows another token shows no message", async () => { const fail = holdFailure(); addressToken.show(); state.selectedToken = TOKEN; addressToken.show(); await settle(); const before = node(ADDRESS_TOKEN.listId).innerHTML; fail(); await settle(); expect(node(ADDRESS_TOKEN.listId).innerHTML).toBe(before); }); test("storedNetworkIs(): a failed read counts as no", async () => { globalThis.chrome.storage.local = { get: async () => { throw new Error("storage is down"); }, }; await expect(storedNetworkIs("mainnet")).resolves.toBe(false); });