diff --git a/README.md b/README.md index e20ba2b..0df1eb7 100644 --- a/README.md +++ b/README.md @@ -905,8 +905,8 @@ src/ prices.js — ETH/USD and token/USD from Chainlink price feeds scamlist.js — known fraud contract addresses state.js — persisted state (extension storage) - storedSettings.js — "Use Blockscout" and "Check recipients on Etherscan", - read from storage for the requests those settings gate + storedSettings.js — "Use Blockscout", "Check recipients on Etherscan" and + the active network, read from storage symbolSpoof.js — the known-symbol spoof rule, shared by all surfaces tokenList.js — top ERC-20 tokens by market cap, and their Chainlink price feeds (hardcoded) diff --git a/src/popup/views/addressDetail.js b/src/popup/views/addressDetail.js index f633893..bc5808e 100644 --- a/src/popup/views/addressDetail.js +++ b/src/popup/views/addressDetail.js @@ -22,6 +22,7 @@ const { filterTransactions, } = require("../../shared/transactions"); const { resolveEnsNames } = require("../../shared/ens"); +const { storedNetworkIs } = require("../../shared/storedSettings"); const { updateSendBalance, renderSendTokenSelect, @@ -98,6 +99,7 @@ let loadedTxs = []; let ensNameMap = new Map(); async function loadTransactions(address) { + const networkId = state.networkId; try { const rawTxs = await fetchRecentTransactions( address, @@ -114,43 +116,74 @@ async function loadTransactions(address) { }); const txs = result.transactions; - // Persist any newly discovered fraud contracts - if (result.newFraudContracts.length > 0) { - for (const addr of result.newFraudContracts) { - if (!state.fraudContracts.includes(addr)) { - state.fraudContracts.push(addr); - } - } - await saveState(); - } - - loadedTxs = txs; - // Collect ALL unique addresses (from + to) for ENS resolution so // that reverse lookups work for every displayed address, not just // the ones that were originally entered as ENS names. const counterparties = [ ...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))), ]; + let names = new Map(); if (counterparties.length > 0) { try { - ensNameMap = await resolveEnsNames( + names = await resolveEnsNames( counterparties, state.rpcUrl, state.networkId, ctx.pageClosed, ); } catch { - ensNameMap = new Map(); + // The rows then show no ENS names. } } + // If the network changed during the lookups above, or the screen now + // shows another address, nothing is saved or drawn. See + // src/shared/storedSettings.js. + if ( + state.networkId !== networkId || + !(await storedNetworkIs(networkId)) || + $("address-line").dataset.full !== address + ) { + return; + } + + // Persist any newly discovered fraud contracts, inside the save and + // holding the lock. Nothing is added unless this page and the stored + // record both still name the request's network. + if (result.newFraudContracts.length > 0) { + let saved = false; + await saveState((stored) => { + if (state.networkId !== networkId) return false; + if (stored.networkId !== networkId) return false; + for (const addr of result.newFraudContracts) { + if (!state.fraudContracts.includes(addr)) { + state.fraudContracts.push(addr); + } + } + saved = true; + }); + // If the network changed while the save waited for the lock, + // nothing is drawn. + if (!saved) return; + } + + loadedTxs = txs; + ensNameMap = names; renderTransactions(txs); } catch (e) { // Cancelled by the popup closing, not failed: see pageClosed in // src/popup/index.js. if (ctx.pageClosed.aborted) return; log.errorf("loadTransactions failed:", e.message); + // If the network changed, or the screen now shows another address, + // the failure is not shown. + if ( + state.networkId !== networkId || + !(await storedNetworkIs(networkId)) || + $("address-line").dataset.full !== address + ) { + return; + } $("tx-list").innerHTML = '
Failed to load transactions.
'; } diff --git a/src/popup/views/addressToken.js b/src/popup/views/addressToken.js index 1af8e46..33584cf 100644 --- a/src/popup/views/addressToken.js +++ b/src/popup/views/addressToken.js @@ -29,6 +29,7 @@ const { filterTransactions, } = require("../../shared/transactions"); const { resolveEnsNames } = require("../../shared/ens"); +const { storedNetworkIs } = require("../../shared/storedSettings"); const { updateSendBalance, renderSendTokenSelect, @@ -170,6 +171,7 @@ function show() { } async function loadTransactions(address, tokenId) { + const networkId = state.networkId; try { const rawTxs = await fetchRecentTransactions( address, @@ -186,16 +188,6 @@ async function loadTransactions(address, tokenId) { }); let txs = result.transactions; - // Persist any newly discovered fraud contracts - if (result.newFraudContracts.length > 0) { - for (const addr of result.newFraudContracts) { - if (!state.fraudContracts.includes(addr)) { - state.fraudContracts.push(addr); - } - } - await saveState(); - } - // Filter to this token only if (tokenId === "ETH") { txs = txs.filter((tx) => tx.contractAddress === null); @@ -207,32 +199,75 @@ async function loadTransactions(address, tokenId) { ); } - loadedTxs = txs; - // Collect ALL unique addresses for ENS resolution so reverse // lookups work for every displayed address. const counterparties = [ ...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))), ]; + let names = new Map(); if (counterparties.length > 0) { try { - ensNameMap = await resolveEnsNames( + names = await resolveEnsNames( counterparties, state.rpcUrl, state.networkId, ctx.pageClosed, ); } catch { - ensNameMap = new Map(); + // The rows then show no ENS names. } } + // If the network changed during the lookups above, or the screen now + // shows another address or token, nothing is saved or drawn. See + // src/shared/storedSettings.js. + if ( + state.networkId !== networkId || + !(await storedNetworkIs(networkId)) || + $("address-token-line").dataset.full !== address || + state.selectedToken !== tokenId + ) { + return; + } + + // Persist any newly discovered fraud contracts, inside the save and + // holding the lock. Nothing is added unless this page and the stored + // record both still name the request's network. + if (result.newFraudContracts.length > 0) { + let saved = false; + await saveState((stored) => { + if (state.networkId !== networkId) return false; + if (stored.networkId !== networkId) return false; + for (const addr of result.newFraudContracts) { + if (!state.fraudContracts.includes(addr)) { + state.fraudContracts.push(addr); + } + } + saved = true; + }); + // If the network changed while the save waited for the lock, + // nothing is drawn. + if (!saved) return; + } + + loadedTxs = txs; + ensNameMap = names; renderTransactions(txs); } catch (e) { // Cancelled by the popup closing, not failed: see pageClosed in // src/popup/index.js. if (ctx.pageClosed.aborted) return; log.errorf("loadTransactions failed:", e.message); + // If the network changed, or the screen now shows another address or + // token, the failure is not shown. + if ( + state.networkId !== networkId || + !(await storedNetworkIs(networkId)) || + $("address-token-line").dataset.full !== address || + state.selectedToken !== tokenId + ) { + return; + } $("address-token-tx-list").innerHTML = '
Failed to load transactions.
'; } diff --git a/src/popup/views/home.js b/src/popup/views/home.js index 0f54fae..43a76e2 100644 --- a/src/popup/views/home.js +++ b/src/popup/views/home.js @@ -44,6 +44,7 @@ const { fetchRecentTransactions, filterTransactions, } = require("../../shared/transactions"); +const { storedNetworkIs } = require("../../shared/storedSettings"); const { log } = require("../../shared/log"); function findActiveAddr() { @@ -183,6 +184,7 @@ async function loadHomeTxs(ctx) { } if (allAddresses.length === 0) return; + const networkId = state.networkId; const filters = { hideSpoofedSymbols: state.hideSpoofedSymbols, hideLowHolderTokens: state.hideLowHolderTokens, @@ -202,6 +204,15 @@ async function loadHomeTxs(ctx) { ); const results = await Promise.all(fetches); + // If the network changed during the lookups, nothing is saved or + // drawn. See src/shared/storedSettings.js. + if ( + state.networkId !== networkId || + !(await storedNetworkIs(networkId)) + ) { + return; + } + // Merge, deduplicate by hash, filter, sort, take 25 const seen = new Set(); let merged = []; @@ -215,14 +226,24 @@ async function loadHomeTxs(ctx) { const filtered = filterTransactions(merged, filters); - // Persist any newly discovered fraud contracts + // Persist any newly discovered fraud contracts, inside the save and + // holding the lock. Nothing is added unless this page and the stored + // record both still name the request's network. if (filtered.newFraudContracts.length > 0) { - for (const addr of filtered.newFraudContracts) { - if (!state.fraudContracts.includes(addr)) { - state.fraudContracts.push(addr); + let saved = false; + await saveState((stored) => { + if (state.networkId !== networkId) return false; + if (stored.networkId !== networkId) return false; + for (const addr of filtered.newFraudContracts) { + if (!state.fraudContracts.includes(addr)) { + state.fraudContracts.push(addr); + } } - } - await saveState(); + saved = true; + }); + // If the network changed while the save waited for the lock, + // nothing is drawn. + if (!saved) return; } merged = filtered.transactions; @@ -234,6 +255,13 @@ async function loadHomeTxs(ctx) { // src/popup/index.js. if (ctx.pageClosed.aborted) return; log.errorf("loadHomeTxs failed:", e.message); + // If the network changed, the failure is not shown. + if ( + state.networkId !== networkId || + !(await storedNetworkIs(networkId)) + ) { + return; + } const list = $("home-tx-list"); if (list) { list.innerHTML = diff --git a/src/shared/storedSettings.js b/src/shared/storedSettings.js index 9086e54..4cf7cac 100644 --- a/src/shared/storedSettings.js +++ b/src/shared/storedSettings.js @@ -1,5 +1,5 @@ -// Reads "Use Blockscout" or "Check recipients on Etherscan" from the stored -// profile. +// Reads "Use Blockscout", "Check recipients on Etherscan" or the active network +// from the stored profile. // // Before each request that sends the user's data to Blockscout or Etherscan, the // wallet reads that service's setting from storage. The wallet reads the setting @@ -7,6 +7,11 @@ // made, and an answer that lands after the setting went off is dropped. A page's // own copy of the setting is not used, because another page may have just // changed it. +// +// Before asking Blockscout for the transaction history, a screen remembers +// which network is active. If another network is active on the page or in +// storage when the answer lands, the screen drops the answer. A switch saved +// elsewhere reaches storage first and the page later. const { storageGet } = require("./browserApi"); const { normalizePersisted } = require("./persistedState"); @@ -24,4 +29,15 @@ async function storedSettingIsOn(name) { } } -module.exports = { storedSettingIsOn }; +// Whether `networkId` is the active network in the stored profile. A failed +// read counts as no. +async function storedNetworkIs(networkId) { + try { + const { autistmask } = await storageGet("autistmask"); + return normalizePersisted(autistmask).networkId === networkId; + } catch { + return false; + } +} + +module.exports = { storedSettingIsOn, storedNetworkIs }; diff --git a/tests/historyAfterNetworkSwitch.test.js b/tests/historyAfterNetworkSwitch.test.js new file mode 100644 index 0000000..18f00af --- /dev/null +++ b/tests/historyAfterNetworkSwitch.test.js @@ -0,0 +1,493 @@ +// 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); +});