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);
+});