1 Commits
Author SHA1 Message Date
sneak ee1d20208f 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 remember which network is active
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 the
answer lands. AddressDetail and AddressToken also drop an answer that lands
once the screen shows another address. AddressToken also drops an answer
that lands once the screen shows another token.

A dropped answer draws no rows and saves no fraud contracts. A failed
request goes through the same checks. A dropped failure shows no message.
Holding the lock, the save adds new fraud contracts only if the page and
the stored record still name the request's network.

storedNetworkIs() reads the stored network, in src/shared/storedSettings.js.

Model: opus-5-5
2026-10-27 10:35:42 +00:00
5 changed files with 183 additions and 28 deletions
+25 -6
View File
@@ -147,14 +147,24 @@ async function loadTransactions(address) {
return;
}
// 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 (result.newFraudContracts.length > 0) {
for (const addr of result.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 result.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;
}
loadedTxs = txs;
@@ -165,6 +175,15 @@ async function loadTransactions(address) {
// 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 =
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
}
+26 -6
View File
@@ -230,14 +230,24 @@ async function loadTransactions(address, tokenId) {
return;
}
// 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 (result.newFraudContracts.length > 0) {
for (const addr of result.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 result.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;
}
loadedTxs = txs;
@@ -248,6 +258,16 @@ async function loadTransactions(address, tokenId) {
// 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 =
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
}
+23 -6
View File
@@ -226,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;
@@ -245,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 =
+4 -4
View File
@@ -8,10 +8,10 @@
// 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 notes the
// active network. 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.
// 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");
+105 -6
View File
@@ -2,8 +2,8 @@
// 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
// 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.
@@ -35,7 +35,8 @@ const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const SPOOF_CONTRACT = "0x3333333333333333333333333333333333333333";
const TOKEN = "0x6B175474E89094C44Da98b954EedeAC495271d0F";
// A profile on Ethereum Mainnet with two addresses and "Use Blockscout" on.
// A profile on Ethereum Mainnet with two addresses, "Use Blockscout" on and no
// fraud contracts.
function profile() {
return {
schemaVersion: STATE_SCHEMA_VERSION,
@@ -62,6 +63,7 @@ function profile() {
rpcUrl: "https://ethereum-rpc.publicnode.com",
blockscoutUrl: "https://eth.blockscout.com/api/v2",
useBlockscout: true,
fraudContracts: [],
};
}
@@ -107,18 +109,48 @@ function history() {
}
// The next history request waits until the returned function is called, and
// then answers with history().
function holdHistory() {
// then answers with `answer`.
function holdHistory(answer = history()) {
let land;
fetchRecentTransactions.mockImplementationOnce(
() =>
new Promise((resolve) => {
land = () => resolve(history());
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,
@@ -263,6 +295,73 @@ describe.each([HOME, ADDRESS_DETAIL, ADDRESS_TOKEN])("$name", (screen) => {
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) => {