1 Commits
Author SHA1 Message Date
sneak 1c90c5a565 fix: a transaction history that lands after a network switch is dropped (closes #657)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
Home, AddressDetail and AddressToken note the active network before they
ask Blockscout for the transaction history. Each screen drops the answer
if another network is active on the page or in storage when it lands.
AddressDetail and AddressToken also drop it once the screen shows another
address, and AddressToken once it shows another token. A dropped answer
draws no rows and saves no fraud contracts.

storedNetworkIs() reads the stored network, beside storedSettingIsOn() in
src/shared/storedSettings.js. The address screens now run the ENS lookup
before the check, so a switch during that lookup is caught too.

Model: opus-5-5
2026-10-27 09:47:07 +00:00
5 changed files with 28 additions and 183 deletions

No files matched your search

+6 -25
View File
@@ -147,24 +147,14 @@ async function loadTransactions(address) {
return; return;
} }
// Persist any newly discovered fraud contracts, inside the save and // Persist any newly discovered fraud contracts
// 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) { if (result.newFraudContracts.length > 0) {
let saved = false; for (const addr of result.newFraudContracts) {
await saveState((stored) => { if (!state.fraudContracts.includes(addr)) {
if (state.networkId !== networkId) return false; state.fraudContracts.push(addr);
if (stored.networkId !== networkId) return false;
for (const addr of result.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
} }
saved = true; }
}); await saveState();
// If the network changed while the save waited for the lock,
// nothing is drawn.
if (!saved) return;
} }
loadedTxs = txs; loadedTxs = txs;
@@ -175,15 +165,6 @@ async function loadTransactions(address) {
// src/popup/index.js. // src/popup/index.js.
if (ctx.pageClosed.aborted) return; if (ctx.pageClosed.aborted) return;
log.errorf("loadTransactions failed:", e.message); 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 = $("tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>'; '<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
} }
+6 -26
View File
@@ -230,24 +230,14 @@ async function loadTransactions(address, tokenId) {
return; return;
} }
// Persist any newly discovered fraud contracts, inside the save and // Persist any newly discovered fraud contracts
// 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) { if (result.newFraudContracts.length > 0) {
let saved = false; for (const addr of result.newFraudContracts) {
await saveState((stored) => { if (!state.fraudContracts.includes(addr)) {
if (state.networkId !== networkId) return false; state.fraudContracts.push(addr);
if (stored.networkId !== networkId) return false;
for (const addr of result.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
} }
saved = true; }
}); await saveState();
// If the network changed while the save waited for the lock,
// nothing is drawn.
if (!saved) return;
} }
loadedTxs = txs; loadedTxs = txs;
@@ -258,16 +248,6 @@ async function loadTransactions(address, tokenId) {
// src/popup/index.js. // src/popup/index.js.
if (ctx.pageClosed.aborted) return; if (ctx.pageClosed.aborted) return;
log.errorf("loadTransactions failed:", e.message); 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 = $("address-token-tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>'; '<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
} }
+6 -23
View File
@@ -226,24 +226,14 @@ async function loadHomeTxs(ctx) {
const filtered = filterTransactions(merged, filters); const filtered = filterTransactions(merged, filters);
// Persist any newly discovered fraud contracts, inside the save and // Persist any newly discovered fraud contracts
// 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) { if (filtered.newFraudContracts.length > 0) {
let saved = false; for (const addr of filtered.newFraudContracts) {
await saveState((stored) => { if (!state.fraudContracts.includes(addr)) {
if (state.networkId !== networkId) return false; state.fraudContracts.push(addr);
if (stored.networkId !== networkId) return false;
for (const addr of filtered.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
} }
saved = true; }
}); await saveState();
// If the network changed while the save waited for the lock,
// nothing is drawn.
if (!saved) return;
} }
merged = filtered.transactions; merged = filtered.transactions;
@@ -255,13 +245,6 @@ async function loadHomeTxs(ctx) {
// src/popup/index.js. // src/popup/index.js.
if (ctx.pageClosed.aborted) return; if (ctx.pageClosed.aborted) return;
log.errorf("loadHomeTxs failed:", e.message); 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"); const list = $("home-tx-list");
if (list) { if (list) {
list.innerHTML = list.innerHTML =
+4 -4
View File
@@ -8,10 +8,10 @@
// own copy of the setting is not used, because another page may have just // own copy of the setting is not used, because another page may have just
// changed it. // changed it.
// //
// Before asking Blockscout for the transaction history, a screen remembers // Before asking Blockscout for the transaction history, a screen notes the
// which network is active. If another network is active on the page or in // active network. If another network is active on the page or in storage when
// storage when the answer lands, the screen drops the answer. A switch saved // the answer lands, the screen drops the answer. A switch saved elsewhere
// elsewhere reaches storage first and the page later. // reaches storage first and the page later.
const { storageGet } = require("./browserApi"); const { storageGet } = require("./browserApi");
const { normalizePersisted } = require("./persistedState"); const { normalizePersisted } = require("./persistedState");
+6 -105
View File
@@ -2,8 +2,8 @@
// lands after a network switch. A dropped answer draws no rows and saves no // lands after a network switch. A dropped answer draws no rows and saves no
// fraud contracts (https://git.eeqj.de/sneak/AutistMask/issues/657). // fraud contracts (https://git.eeqj.de/sneak/AutistMask/issues/657).
// //
// Most tests hold the history request open, switch the network or what the // Each test holds the history request open, switches the network or what the
// screen shows, and then let the answer land. The views run against a minimal // screen shows, and then lets the answer land. The views run against a minimal
// DOM stub, as in tests/transactionListCancelled.test.js, and over // DOM stub, as in tests/transactionListCancelled.test.js, and over
// tests/support/storageStub.js. // tests/support/storageStub.js.
@@ -35,8 +35,7 @@ const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const SPOOF_CONTRACT = "0x3333333333333333333333333333333333333333"; const SPOOF_CONTRACT = "0x3333333333333333333333333333333333333333";
const TOKEN = "0x6B175474E89094C44Da98b954EedeAC495271d0F"; const TOKEN = "0x6B175474E89094C44Da98b954EedeAC495271d0F";
// A profile on Ethereum Mainnet with two addresses, "Use Blockscout" on and no // A profile on Ethereum Mainnet with two addresses and "Use Blockscout" on.
// fraud contracts.
function profile() { function profile() {
return { return {
schemaVersion: STATE_SCHEMA_VERSION, schemaVersion: STATE_SCHEMA_VERSION,
@@ -63,7 +62,6 @@ function profile() {
rpcUrl: "https://ethereum-rpc.publicnode.com", rpcUrl: "https://ethereum-rpc.publicnode.com",
blockscoutUrl: "https://eth.blockscout.com/api/v2", blockscoutUrl: "https://eth.blockscout.com/api/v2",
useBlockscout: true, useBlockscout: true,
fraudContracts: [],
}; };
} }
@@ -109,48 +107,18 @@ function history() {
} }
// The next history request waits until the returned function is called, and // The next history request waits until the returned function is called, and
// then answers with `answer`. // then answers with history().
function holdHistory(answer = history()) { function holdHistory() {
let land; let land;
fetchRecentTransactions.mockImplementationOnce( fetchRecentTransactions.mockImplementationOnce(
() => () =>
new Promise((resolve) => { new Promise((resolve) => {
land = () => resolve(answer); land = () => resolve(history());
}), }),
); );
return () => land(); 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) { function makeElement(id) {
const el = { const el = {
id, id,
@@ -295,73 +263,6 @@ describe.each([HOME, ADDRESS_DETAIL, ADDRESS_TOKEN])("$name", (screen) => {
expectNothingAdded(screen, before); 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("an answer that lands once the page has switched and storage has not adds nothing", async () => {
const land = holdHistory(history().slice(0, 1));
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
// The switch's own save has changed the page and has not yet written
// storage.
applyChainSwitchFields(state, "sepolia");
land();
await settle();
expectNothingAdded(screen, before);
expect(storage.read("autistmask").networkId).toBe("mainnet");
});
// 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);
});
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) => { describe.each([ADDRESS_DETAIL, ADDRESS_TOKEN])("$name", (screen) => {