// 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). // // Each test holds the history request open, switches the network or what the // screen shows, and then lets 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 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 and "Use Blockscout" on. 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, }; } // 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 history(). function holdHistory() { let land; fetchRecentTransactions.mockImplementationOnce( () => new Promise((resolve) => { land = () => resolve(history()); }), ); return () => land(); } 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 pending promise 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(), }; // The two ways a network switch reaches the page. 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 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(); const before = node(screen.listId).innerHTML; await to("sepolia"); land(); await settle(); expectNothingAdded(screen, before); }, ); }); 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("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); });