// 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). // // 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, }, { 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, }, ]; } // 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; }; } 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); }); }); 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); }); 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()); 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); });