2 Commits
Author SHA1 Message Date
sneak 4f46515d87 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 11:57:54 +00:00
clawbot bcdffa56a4 fix: Settings stores the URL on screen when another page stored a different one (closes #688)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
The RPC and Blockscout "Save" buttons in Settings now set the URL in the stored record, inside the save. Before this change, the save wrote only fields that differed from what the page last saved. If another page stored a different URL for the same network, a "Save" of the URL this page loaded left the other page's URL stored, and the flash still read "Saved.". The save still refuses a URL when the active network is no longer the one Settings showed.

Model: opus-5-5
2026-10-27 12:36:48 +01:00
4 changed files with 153 additions and 17 deletions

No files matched your search

+2 -2
View File
@@ -905,8 +905,8 @@ src/
prices.js — ETH/USD and token/USD from Chainlink price feeds prices.js — ETH/USD and token/USD from Chainlink price feeds
scamlist.js — known fraud contract addresses scamlist.js — known fraud contract addresses
state.js — persisted state (extension storage) state.js — persisted state (extension storage)
storedSettings.js — "Use Blockscout" and "Check recipients on Etherscan", storedSettings.js — "Use Blockscout", "Check recipients on Etherscan" and
read from storage for the requests those settings gate the active network, read from storage
symbolSpoof.js — the known-symbol spoof rule, shared by all surfaces symbolSpoof.js — the known-symbol spoof rule, shared by all surfaces
tokenList.js — top ERC-20 tokens by market cap, and their Chainlink tokenList.js — top ERC-20 tokens by market cap, and their Chainlink
price feeds (hardcoded) price feeds (hardcoded)
+6
View File
@@ -334,6 +334,11 @@ function showNewSettings() {
// src/popup/index.js) between setting the URL and writing it. If the active // src/popup/index.js) between setting the URL and writing it. If the active
// network is no longer `networkId`, on this page or in the stored record, // network is no longer `networkId`, on this page or in the stored record,
// nothing is saved. The flash message then says that the network changed. // nothing is saved. The flash message then says that the network changed.
//
// The save writes only the fields that differ from what this page last saved.
// Another page may have stored a different URL since then. So `url` is set in
// the stored record too. "Saved." then means that the stored record holds
// `url`.
async function saveEndpoint(field, url, networkId) { async function saveEndpoint(field, url, networkId) {
let refused = false; let refused = false;
await saveState((stored) => { await saveState((stored) => {
@@ -341,6 +346,7 @@ async function saveEndpoint(field, url, networkId) {
state.networkId !== networkId || stored.networkId !== networkId; state.networkId !== networkId || stored.networkId !== networkId;
if (refused) return false; if (refused) return false;
state[field] = url; state[field] = url;
stored[field] = url;
}); });
if (refused) { if (refused) {
showFlash("The URL was not saved because the network changed."); showFlash("The URL was not saved because the network changed.");
+96 -15
View File
@@ -23,6 +23,7 @@ const { resolveEnsNames } = require("../src/shared/ens");
const { state, loadState } = require("../src/shared/state"); const { state, loadState } = require("../src/shared/state");
const { onChainSwitch } = require("../src/shared/chainSwitch"); const { onChainSwitch } = require("../src/shared/chainSwitch");
const { applyChainSwitchFields } = require("../src/shared/chainSwitchFields"); const { applyChainSwitchFields } = require("../src/shared/chainSwitchFields");
const { storedNetworkIs } = require("../src/shared/storedSettings");
const home = require("../src/popup/views/home"); const home = require("../src/popup/views/home");
const addressDetail = require("../src/popup/views/addressDetail"); const addressDetail = require("../src/popup/views/addressDetail");
const addressToken = require("../src/popup/views/addressToken"); const addressToken = require("../src/popup/views/addressToken");
@@ -196,7 +197,7 @@ function node(id) {
return globalThis.document.getElementById(id); return globalThis.document.getElementById(id);
} }
// Lets every pending promise settle. // Lets every promise that is not held open settle.
function settle() { function settle() {
return new Promise((resolve) => setTimeout(resolve, 0)); return new Promise((resolve) => setTimeout(resolve, 0));
} }
@@ -242,7 +243,11 @@ const ADDRESS_TOKEN = {
open: () => addressToken.show(), open: () => addressToken.show(),
}; };
// The two ways a network switch reaches the page. // 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 = [ const SWITCHES = [
{ {
how: "made in Settings", how: "made in Settings",
@@ -258,6 +263,14 @@ const SWITCHES = [
storage.write("autistmask", record); 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 // The list still shows `before`, and neither the page nor storage holds a
@@ -287,6 +300,7 @@ describe.each([HOME, ADDRESS_DETAIL, ADDRESS_TOKEN])("$name", (screen) => {
async ({ to }) => { async ({ to }) => {
const land = holdHistory(); const land = holdHistory();
screen.open(); screen.open();
await settle();
const before = node(screen.listId).innerHTML; const before = node(screen.listId).innerHTML;
await to("sepolia"); await to("sepolia");
land(); land();
@@ -298,20 +312,35 @@ describe.each([HOME, ADDRESS_DETAIL, ADDRESS_TOKEN])("$name", (screen) => {
// The answer is the transfer of ETH alone, which adds no fraud contract. // 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. // 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 () => { test.each(SWITCHES)(
const land = holdHistory(history().slice(0, 1)); "an answer without a fraud contract that lands after a switch $how adds nothing",
screen.open(); async ({ to }) => {
await settle(); const land = holdHistory(history().slice(0, 1));
const before = node(screen.listId).innerHTML; screen.open();
// The switch's own save has changed the page and has not yet written await settle();
// storage. const before = node(screen.listId).innerHTML;
applyChainSwitchFields(state, "sepolia"); await to("sepolia");
land(); land();
await settle(); await settle();
expectNothingAdded(screen, before); expectNothingAdded(screen, before);
expect(storage.read("autistmask").networkId).toBe("mainnet"); },
}); );
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 user picks Sepolia in Settings while another write holds the lock.
// The answer lands, and its save waits behind the switch. // The answer lands, and its save waits behind the switch.
@@ -348,6 +377,22 @@ describe.each([HOME, ADDRESS_DETAIL, ADDRESS_TOKEN])("$name", (screen) => {
expectNothingAdded(screen, before); 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 () => { test("a request that fails after a switch leaves the new network's rows", async () => {
const fail = holdFailure(); const fail = holdFailure();
screen.open(); screen.open();
@@ -378,6 +423,19 @@ describe.each([ADDRESS_DETAIL, ADDRESS_TOKEN])("$name", (screen) => {
expectNothingAdded(screen, before); 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 () => { test("an answer whose name lookup lands after a switch adds nothing", async () => {
let landNames; let landNames;
resolveEnsNames.mockImplementationOnce( resolveEnsNames.mockImplementationOnce(
@@ -410,3 +468,26 @@ test("AddressToken: an answer that lands once the screen shows another token add
expectNothingAdded(ADDRESS_TOKEN, before); 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);
});
+49
View File
@@ -0,0 +1,49 @@
// Another page stores a different RPC or Blockscout URL for the same network.
// Then the user taps "Save" in Settings on this page, on the URL that this page
// loaded. The URL on screen is stored, and the flash message reads "Saved."
// (https://git.eeqj.de/sneak/AutistMask/issues/688). Driven through the real
// popup over a stored profile on Ethereum Mainnet.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
} = require("./support/popupBoot");
const { NETWORKS } = require("../src/shared/networks");
afterEach(() => {
cleanupPopup();
});
test.each([
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
},
])(
"$button stores the URL on screen after another page stored a different one",
async ({ button, input, field }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const url = page.value(input);
// This write sends this page no storage change event.
page.storage.write("autistmask", {
...page.storage.read("autistmask"),
[field]: "https://other-page.example.invalid",
});
// This answer passes both endpoint checks on Ethereum Mainnet. The RPC
// check reads Ethereum Mainnet's chain id from it. The Blockscout check
// reads `ok`.
globalThis.fetch.mockResolvedValue({
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
});
await page.click(button);
expect(page.storage.read("autistmask")[field]).toBe(url);
expect(page.text("flash-msg")).toBe("Saved.");
},
);