harden: stop the background reading the shared state singleton, and enforce it at build time (closes #324)
All checks were successful
check / check (push) Successful in 33s
e2e / e2e-chrome (push) Successful in 1m45s
e2e / e2e-firefox (push) Successful in 31s

Five defects, one of which destroyed every wallet, came from src/background reading and writing the module-level state singleton the MV3 worker never populates, which silently served DEFAULT_STATE. Each point fix created the next defect. The background now has its own per-call getState() and a queued read-modify-write updateState(); the singleton is unreachable from it, and an unpopulated read throws instead of serving defaults.

The prohibition is enforced by the build, not by review: build.js asserts over esbuild's own metafile that no forbidden module is an input of a background bundle, so every specifier syntax esbuild resolves is covered, and both halves of the table are checked for rot -- a stale key, a stale module, an empty list, or an unlisted entry point under src/background/ all fail the build. The ESLint rule remains as fast local feedback and reads the same shared table. Known bounds are documented where the table lives.

Also closes #320: getProvider() now requires a validated network id, so a cold worker no longer prepares a non-mainnet dApp transaction for mainnet and gets refused by the wallet's own verifier. backgroundRefresh() no longer mutates address objects across a network round trip, the broadcast path takes its endpoint and chain id from one snapshot, and eight test storage stubs now structured-clone on get as the real chrome.storage.local does.

closes #320
This commit was merged in pull request #344.
This commit is contained in:
2026-08-23 17:57:30 +02:00
parent 36bc6bee0e
commit bd0a626e7b
40 changed files with 2959 additions and 653 deletions

View File

@@ -9,6 +9,7 @@ const {
formatUnits,
} = require("ethers");
const { ERC20_ABI } = require("./constants");
const { NETWORKS } = require("./networks");
const { log, debugFetch } = require("./log");
const { deriveAddressFromXpub } = require("./wallet");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
@@ -17,17 +18,38 @@ const { isSpoofedSymbol } = require("./symbolSpoof");
// Use a static network to skip auto-detection (which can fail and cause
// "could not coalesce error" on some RPC endpoints like Cloudflare).
// Accepts an optional networkName ("mainnet" or "sepolia") for the static
// network hint so ethers picks the right chain parameters. When omitted,
// reads the currently selected network from extension state.
function getProvider(rpcUrl, networkName) {
// Lazy require to avoid circular dependency issues at module scope.
const { currentNetwork } = require("./state");
const name = networkName || currentNetwork().id;
const net = Network.from(name);
//
// `networkId` is REQUIRED, and is one of the ids in networks.js. It used to be
// optional, falling back to currentNetwork() — the module-level `state`
// singleton, which the MV3 service worker never populates. The endpoint then
// came out right and the static hint came out mainnet, so ethers fixed
// `chainId` at 0x1 and every non-mainnet dApp send was prepared for the wrong
// chain and then refused by the wallet's own verifier
// (https://git.eeqj.de/sneak/AutistMask/issues/320). Requiring it is what
// stops that from coming back: a caller that has no network to name has no
// business constructing a provider, and there is no longer a default for it
// to get silently wrong.
//
// Validated against NETWORKS rather than passed straight to Network.from():
// ethers knows chains this wallet does not, so an id that is not one of ours
// is a caller bug and must not resolve to a working provider for some other
// chain.
function getProvider(rpcUrl, networkId) {
const net = Network.from(requireNetworkId(networkId).id);
return new JsonRpcProvider(rpcUrl, net, { staticNetwork: net });
}
function requireNetworkId(networkId) {
const net = NETWORKS[networkId];
if (!net) {
throw new Error(
"getProvider requires the id of a supported network; got " +
JSON.stringify(networkId),
);
}
return net;
}
function formatBalance(wei) {
const eth = formatEther(wei);
const parts = eth.split(".");
@@ -118,9 +140,15 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
}
// Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses.
async function refreshBalances(wallets, rpcUrl, blockscoutUrl, trackedTokens) {
async function refreshBalances(
wallets,
rpcUrl,
blockscoutUrl,
trackedTokens,
networkId,
) {
log.debugf("refreshBalances start, rpc:", rpcUrl);
const provider = getProvider(rpcUrl);
const provider = getProvider(rpcUrl, networkId);
const updates = [];
for (const wallet of wallets) {
@@ -193,9 +221,9 @@ async function refreshBalances(wallets, rpcUrl, blockscoutUrl, trackedTokens) {
// Look up token metadata from its contract.
// Calls symbol() and decimals() to verify it implements ERC-20.
async function lookupTokenInfo(contractAddress, rpcUrl) {
async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
log.debugf("lookupTokenInfo", contractAddress, "rpc:", rpcUrl);
const provider = getProvider(rpcUrl);
const provider = getProvider(rpcUrl, networkId);
const contract = new Contract(contractAddress, ERC20_ABI, provider);
let name, symbol, decimals;
@@ -235,9 +263,9 @@ async function lookupTokenInfo(contractAddress, rpcUrl) {
// Checks gapLimit addresses in parallel per batch. Stops when an entire
// batch has no used addresses (i.e. gapLimit consecutive empty addresses).
// Returns { addresses: [{ address, index }], nextIndex }.
async function scanForAddresses(xpub, rpcUrl, gapLimit = 5) {
async function scanForAddresses(xpub, rpcUrl, networkId, gapLimit = 5) {
log.debugf("scanForAddresses start, gapLimit:", gapLimit);
const provider = getProvider(rpcUrl);
const provider = getProvider(rpcUrl, networkId);
const used = [];
let checked = 0;
let checkUpTo = gapLimit;

View File

@@ -1,14 +1,23 @@
// Consolidated chain-switch handler.
// Consolidated chain-switch handler for the popup.
//
// Every state change required when the active network changes is
// performed here so that callers (settings UI, background
// wallet_switchEthereumChain, future chain additions) all go
// performed here so that callers (settings UI, future chain additions) all go
// through a single code path.
//
// Adding a new chain (e.g. ETC) requires only a new entry in
// networks.js — no per-caller wiring is needed.
//
// The background does NOT come through here: this function mutates the
// module-level `state` singleton, which the MV3 service worker never
// populates, and a background switch performed on it wrote DEFAULT_STATE over
// the user's whole profile
// (https://git.eeqj.de/sneak/AutistMask/issues/316). The field mutations
// themselves live in chainSwitchFields.js, which takes the record to mutate as
// an argument; src/background/state.js applies them inside a read-modify-write
// against storage, and the singleton is not reachable from the background
// bundle at all (enforced by the ESLint rule in eslint.config.js).
const { networkById } = require("./networks");
const { applyChainSwitchFields } = require("./chainSwitchFields");
const { clearPrices } = require("./prices");
// Switch the active chain and reset all chain-specific cached state.
@@ -16,56 +25,14 @@ const { clearPrices } = require("./prices");
async function onChainSwitch(newNetworkId) {
const { state, saveState } = require("./state");
const net = networkById(newNetworkId);
// --- core identity ---
// Endpoints are remembered per network rather than reset to the
// defaults, because a user who points the wallet at their own node has
// no way to get that URL back once it is gone: overwriting it moved
// every address and every transaction onto a third-party endpoint
// silently and permanently.
//
// state.rpcUrl / state.blockscoutUrl stay the live endpoints of the
// active network, so nothing that reads them changes. The invariant is
// that for the ACTIVE network those two fields are authoritative and
// the map entry may be stale (Settings writes the fields directly);
// for every other network the map is authoritative. Snapshotting the
// outgoing network here, before the switch, is what reconciles them.
state.networkEndpoints[state.networkId] = {
rpcUrl: state.rpcUrl,
blockscoutUrl: state.blockscoutUrl,
};
const remembered = state.networkEndpoints[net.id] || {};
state.networkId = net.id;
state.rpcUrl = remembered.rpcUrl || net.defaultRpcUrl;
state.blockscoutUrl = remembered.blockscoutUrl || net.defaultBlockscoutUrl;
const net = applyChainSwitchFields(state, newNetworkId);
// --- price cache ---
// Prices are chain-specific (testnet tokens are worthless,
// ETC has different pricing, etc.).
// ETC has different pricing, etc.). In-memory and per bundle, so this is
// the popup's own cache — the only context that ever fills it.
clearPrices();
// --- balance / refresh state ---
// Reset last-refresh timestamp so the next polling cycle
// triggers an immediate balance refresh on the new chain.
state.lastBalanceRefresh = 0;
// Clear per-address balances and token balances so stale data
// from the previous chain is never displayed while the first
// refresh on the new chain is in flight.
for (const wallet of state.wallets) {
for (const addr of wallet.addresses) {
addr.balance = "0";
addr.tokenBalances = [];
}
}
// --- chain-specific caches ---
// Token holder counts and fraud contract lists are
// chain-specific and must not carry over.
state.tokenHolderCache = {};
state.fraudContracts = [];
await saveState();
return net;

View File

@@ -0,0 +1,69 @@
// The field mutations a chain switch performs, applied to a state record
// handed in rather than to the module-level `state` singleton.
//
// Split out of chainSwitch.js so the background can perform a chain switch
// without the singleton being reachable from its bundle at all. The popup
// still goes through onChainSwitch() (chainSwitch.js), which applies this to
// the singleton and saves; the background applies it to the detached record of
// its own read-modify-write (src/background/state.js).
//
// Everything here is synchronous and touches nothing but the object it is
// given: no storage, no caches, no imports beyond the network table. That is
// what makes it usable on a record that has been read fresh from storage
// microseconds earlier and is about to be written back.
const { networkById } = require("./networks");
// Switch `s` to `newNetworkId` and reset every piece of chain-specific state
// it carries. Returns the network configuration object for the new chain.
function applyChainSwitchFields(s, newNetworkId) {
const net = networkById(newNetworkId);
// --- core identity ---
// Endpoints are remembered per network rather than reset to the
// defaults, because a user who points the wallet at their own node has
// no way to get that URL back once it is gone: overwriting it moved
// every address and every transaction onto a third-party endpoint
// silently and permanently.
//
// s.rpcUrl / s.blockscoutUrl stay the live endpoints of the active
// network, so nothing that reads them changes. The invariant is that for
// the ACTIVE network those two fields are authoritative and the map entry
// may be stale (Settings writes the fields directly); for every other
// network the map is authoritative. Snapshotting the outgoing network
// here, before the switch, is what reconciles them.
if (!s.networkEndpoints) s.networkEndpoints = {};
s.networkEndpoints[s.networkId] = {
rpcUrl: s.rpcUrl,
blockscoutUrl: s.blockscoutUrl,
};
const remembered = s.networkEndpoints[net.id] || {};
s.networkId = net.id;
s.rpcUrl = remembered.rpcUrl || net.defaultRpcUrl;
s.blockscoutUrl = remembered.blockscoutUrl || net.defaultBlockscoutUrl;
// --- balance / refresh state ---
// Reset last-refresh timestamp so the next polling cycle
// triggers an immediate balance refresh on the new chain.
s.lastBalanceRefresh = 0;
// Clear per-address balances and token balances so stale data
// from the previous chain is never displayed while the first
// refresh on the new chain is in flight.
for (const wallet of s.wallets || []) {
for (const addr of wallet.addresses || []) {
addr.balance = "0";
addr.tokenBalances = [];
}
}
// --- chain-specific caches ---
// Token holder counts and fraud contract lists are
// chain-specific and must not carry over.
s.tokenHolderCache = {};
s.fraudContracts = [];
return net;
}
module.exports = { applyChainSwitchFields };

View File

@@ -32,11 +32,11 @@ function setCache(address, name) {
localStorage.setItem(key, JSON.stringify({ name, ts: Date.now() }));
}
async function resolveEnsName(address, rpcUrl) {
async function resolveEnsName(address, rpcUrl, networkId) {
const cached = getCached(address);
if (cached !== undefined) return cached;
const provider = getProvider(rpcUrl);
const provider = getProvider(rpcUrl, networkId);
try {
const name = (await provider.lookupAddress(address)) || null;
setCache(address, name);
@@ -48,11 +48,11 @@ async function resolveEnsName(address, rpcUrl) {
}
}
async function resolveEnsNames(addresses, rpcUrl) {
async function resolveEnsNames(addresses, rpcUrl, networkId) {
const results = new Map();
await Promise.all(
addresses.map(async (addr) => {
results.set(addr, await resolveEnsName(addr, rpcUrl));
results.set(addr, await resolveEnsName(addr, rpcUrl, networkId));
}),
);
return results;

View File

@@ -0,0 +1,204 @@
// The shape of the persisted profile, and the normalization every read of it
// goes through. No singleton, no storage access, no browser API: just the
// record definition and pure functions over it.
//
// Split out of state.js so that a context which must never touch the
// module-level `state` singleton can still speak the same record format.
// src/background/state.js is that context — the MV3 service worker never
// populates the singleton, and every defect in
// https://git.eeqj.de/sneak/AutistMask/issues/324 came from background code
// reaching it anyway and being served DEFAULT_STATE.
const { DEFAULT_RPC_URL, DEFAULT_BLOCKSCOUT_URL } = require("./constants");
// Dependency-free constant module; safe to pull into a background bundle.
const { RESTORABLE_VIEWS } = require("../popup/restorableViews");
const DEFAULT_STATE = {
hasWallet: false,
wallets: [],
trackedTokens: [],
networkId: "mainnet",
rpcUrl: DEFAULT_RPC_URL,
blockscoutUrl: DEFAULT_BLOCKSCOUT_URL,
// Endpoints remembered per network: { [networkId]: { rpcUrl,
// blockscoutUrl } }. rpcUrl/blockscoutUrl above are the live endpoints
// of the active network; this is what the others are restored from
// when the active network changes. See applyChainSwitchFields().
networkEndpoints: {},
lastBalanceRefresh: 0,
activeAddress: null,
allowedSites: {},
deniedSites: {},
rememberSiteChoice: true,
showZeroBalanceTokens: true,
hideSpoofedSymbols: true,
hideLowHolderTokens: true,
hideFraudContracts: true,
hideDustTransactions: true,
dustThresholdGwei: 100000,
utcTimestamps: false,
fraudContracts: [],
tokenHolderCache: {},
theme: "system",
debugMode: false,
};
// Every field written to and read from the single "autistmask" storage key.
// hasWallet is deliberately excluded from the diffing/merge logic in
// state.js — like loadState() does, it is always derived from `wallets`,
// never carried as an independent value.
const PERSISTED_FIELDS = Object.keys(DEFAULT_STATE)
.filter((key) => key !== "hasWallet")
.concat([
"currentView",
"selectedWallet",
"selectedAddress",
"selectedToken",
"viewData",
"viewStack",
]);
// Keep only the leading run of stored views the popup is willing to render.
//
// restoreView() refuses to reopen ONTO a non-restorable view, but the stack
// behind it used to be restored verbatim, so Back could walk onto a screen
// whose content is deliberately never re-rendered — and "show-phrase" has no
// Back control to leave by. Truncating at the first such entry instead of
// splicing it out keeps the result a prefix of the stored stack, so every
// surviving entry's Back target is exactly the one it had; splicing would
// silently re-point the entry above the hole at a different screen.
//
// Filtering happens here on load rather than in saveState(): the live
// in-session stack is legitimate (the screen really is rendered while the
// popup is open), and only a load-side filter also repairs the stacks
// already in storage, including ones written before a view left the set.
function restorableStack(stored, currentView) {
// A stored stack that is missing or not an array keeps nothing, but it
// still goes through the never-empty rule below rather than returning
// early: otherwise a corrupt stack would depend on exactly the goBack()
// fallback that the explicit ["main"] exists in order not to depend on.
const source = Array.isArray(stored) ? stored : [];
const cut = source.findIndex((view) => !RESTORABLE_VIEWS.has(view));
const kept = cut === -1 ? source.slice() : source.slice(0, cut);
// A view restored below the root still needs somewhere for Back to go.
if (
kept.length === 0 &&
currentView !== "main" &&
RESTORABLE_VIEWS.has(currentView)
) {
return ["main"];
}
return kept;
}
// Turn a raw stored (or missing) record into the full, defaulted shape
// loadState() used to assign directly onto `state`. A pure function so that
// saveState() can apply it too: the fields THIS page did not change still have
// to come from storage in their loaded-and-normalized form, not as the raw
// bytes another page (or an old release) left there — otherwise a legacy shape
// a load has always self-healed in memory (a missing networkEndpoints map, an
// out-of-range flag) is dropped right back into storage unfixed every time the
// page that DID normalize it saves something unrelated, because that field's
// value never "changed" for that page to notice.
//
// The result never shares structure with `saved`, so a caller may mutate it
// freely: it is the detached record every per-call read in the background is
// built on.
function normalizePersisted(saved) {
saved = saved || {};
const out = {};
out.wallets = structuredClone(saved.wallets || []);
// Derived, never trusted verbatim off storage — see loadState().
out.hasWallet = out.wallets.length > 0;
out.trackedTokens = structuredClone(saved.trackedTokens || []);
out.networkId = saved.networkId || DEFAULT_STATE.networkId;
out.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl;
out.blockscoutUrl = saved.blockscoutUrl || DEFAULT_STATE.blockscoutUrl;
// An actual object is required, not merely a truthy non-array: the code
// below and applyChainSwitchFields() index and ASSIGN INTO this value, and
// assigning a property to a string or a number is a silent no-op in
// sloppy mode. Copied rather than referenced, nested pairs included, so
// normalizing never mutates the object a caller handed in.
const rawEndpoints =
typeof saved.networkEndpoints === "object" &&
saved.networkEndpoints !== null &&
!Array.isArray(saved.networkEndpoints)
? saved.networkEndpoints
: {};
out.networkEndpoints = {};
for (const netId of Object.keys(rawEndpoints)) {
out.networkEndpoints[netId] = { ...rawEndpoints[netId] };
}
// A profile written before this map existed carries exactly one pair of
// endpoints, belonging to whatever network it was last on. Adopt it as
// that network's remembered pair, so a custom endpoint set on the old
// build is not lost by the first switch away and back.
if (!out.networkEndpoints[out.networkId]) {
out.networkEndpoints[out.networkId] = {
rpcUrl: out.rpcUrl,
blockscoutUrl: out.blockscoutUrl,
};
}
out.lastBalanceRefresh = saved.lastBalanceRefresh || 0;
out.activeAddress = saved.activeAddress || null;
out.allowedSites =
saved.allowedSites && !Array.isArray(saved.allowedSites)
? structuredClone(saved.allowedSites)
: {};
out.deniedSites =
saved.deniedSites && !Array.isArray(saved.deniedSites)
? structuredClone(saved.deniedSites)
: {};
out.rememberSiteChoice =
saved.rememberSiteChoice !== undefined
? saved.rememberSiteChoice
: true;
out.showZeroBalanceTokens =
saved.showZeroBalanceTokens !== undefined
? saved.showZeroBalanceTokens
: true;
// A profile written before this setting existed has no key for it. It
// is a safety filter, so absent must load as on, not as undefined.
out.hideSpoofedSymbols =
saved.hideSpoofedSymbols !== undefined
? saved.hideSpoofedSymbols
: true;
out.hideLowHolderTokens =
saved.hideLowHolderTokens !== undefined
? saved.hideLowHolderTokens
: true;
out.hideFraudContracts =
saved.hideFraudContracts !== undefined
? saved.hideFraudContracts
: true;
out.hideDustTransactions =
saved.hideDustTransactions !== undefined
? saved.hideDustTransactions
: true;
out.dustThresholdGwei =
saved.dustThresholdGwei !== undefined
? saved.dustThresholdGwei
: 100000;
out.utcTimestamps =
saved.utcTimestamps !== undefined ? saved.utcTimestamps : false;
out.fraudContracts = structuredClone(saved.fraudContracts || []);
out.tokenHolderCache = structuredClone(saved.tokenHolderCache || {});
out.theme = saved.theme || "system";
out.debugMode = saved.debugMode !== undefined ? saved.debugMode : false;
out.currentView = saved.currentView || null;
out.selectedWallet =
saved.selectedWallet !== undefined ? saved.selectedWallet : null;
out.selectedAddress =
saved.selectedAddress !== undefined ? saved.selectedAddress : null;
out.selectedToken = saved.selectedToken || null;
out.viewData = structuredClone(saved.viewData || {});
out.viewStack = restorableStack(saved.viewStack, out.currentView);
return out;
}
module.exports = {
DEFAULT_STATE,
PERSISTED_FIELDS,
normalizePersisted,
restorableStack,
};

View File

@@ -1,46 +1,42 @@
// State management and extension storage persistence.
//
// The `state` export is a module-level singleton: ONE in-memory copy of the
// profile per bundle, loaded once by loadState() and mutated in place from
// then on. That is the popup's model — one page, one load at boot, one
// lifetime.
//
// It is NOT the background's model, and the background must not reach it. The
// MV3 service worker is torn down when idle and revived by the next message,
// nothing loads state at module scope, and an unpopulated read used to hand
// back DEFAULT_STATE with no complaint — five defects came out of that one
// fact (https://git.eeqj.de/sneak/AutistMask/issues/324). Two things close it:
// this module is unreachable from the background bundle, and reading a
// persisted field of the singleton before a load now THROWS instead of quietly
// serving a default.
//
// The unreachability is enforced by the BUILD. build.js fails when esbuild's
// own metafile reports this module as an input of a background bundle — the
// resolution the shipped file was built from, so no specifier syntax gets past
// it — from the table in script/lib/forbiddenBundleInputs.js, which also
// records what that does and does not cover. The ESLint rule that reports the
// same thing in the editor is fast feedback in front of the build, not the
// guarantee.
const { DEFAULT_RPC_URL, DEFAULT_BLOCKSCOUT_URL } = require("./constants");
const { networkById } = require("./networks");
// Dependency-free constant module; safe to pull into a background bundle.
const { RESTORABLE_VIEWS } = require("../popup/restorableViews");
const {
DEFAULT_STATE,
PERSISTED_FIELDS,
normalizePersisted,
} = require("./persistedState");
const { storageGet, storageSet } = require("./browserApi");
const { log } = require("./log");
const DEFAULT_STATE = {
hasWallet: false,
wallets: [],
trackedTokens: [],
networkId: "mainnet",
rpcUrl: DEFAULT_RPC_URL,
blockscoutUrl: DEFAULT_BLOCKSCOUT_URL,
// Endpoints remembered per network: { [networkId]: { rpcUrl,
// blockscoutUrl } }. rpcUrl/blockscoutUrl above are the live endpoints
// of the active network; this is what the others are restored from
// when the active network changes. See onChainSwitch().
networkEndpoints: {},
lastBalanceRefresh: 0,
activeAddress: null,
allowedSites: {},
deniedSites: {},
rememberSiteChoice: true,
showZeroBalanceTokens: true,
hideSpoofedSymbols: true,
hideLowHolderTokens: true,
hideFraudContracts: true,
hideDustTransactions: true,
dustThresholdGwei: 100000,
utcTimestamps: false,
fraudContracts: [],
tokenHolderCache: {},
theme: "system",
debugMode: false,
};
const state = {
// The live record the proxy below guards. Everything inside this module reads
// and writes THIS object, never the proxy: the guard is for callers.
const rawState = {
...DEFAULT_STATE,
// Its own object, not the one DEFAULT_STATE holds: onChainSwitch()
// Its own object, not the one DEFAULT_STATE holds: applyChainSwitchFields()
// mutates this map in place, and a spread copies the reference.
networkEndpoints: {},
currentView: null,
@@ -51,161 +47,75 @@ const state = {
viewStack: [],
};
// Keep only the leading run of stored views the popup is willing to render.
// False until loadState() has completed in this bundle. Until then, a
// persisted field that has not been assigned in this context cannot be READ:
// see StateNotLoadedError.
let loaded = false;
// True once this context has assigned anything into the singleton.
//
// restoreView() refuses to reopen ONTO a non-restorable view, but the stack
// behind it used to be restored verbatim, so Back could walk onto a screen
// whose content is deliberately never re-rendered — and "show-phrase" has no
// Back control to leave by. Truncating at the first such entry instead of
// splicing it out keeps the result a prefix of the stored stack, so every
// surviving entry's Back target is exactly the one it had; splicing would
// silently re-point the entry above the hole at a different screen.
// What the guard is for is a context that READS a profile nobody put there —
// every one of the five defects was a pure read of an untouched singleton,
// answered out of DEFAULT_STATE. A context that has written into it is
// managing it deliberately (the popup does, via loadState() at boot and by
// hand thereafter), and reading back what you yourself put there is not the
// mistake being caught.
//
// Filtering happens here on load rather than in saveState(): the live
// in-session stack is legitimate (the screen really is rendered while the
// popup is open), and only a load-side filter also repairs the stacks
// already in storage, including ones written before a view left the set.
function restorableStack(stored, currentView) {
// A stored stack that is missing or not an array keeps nothing, but it
// still goes through the never-empty rule below rather than returning
// early: otherwise a corrupt stack would depend on exactly the goBack()
// fallback that the explicit ["main"] exists in order not to depend on.
const source = Array.isArray(stored) ? stored : [];
const cut = source.findIndex((view) => !RESTORABLE_VIEWS.has(view));
const kept = cut === -1 ? source.slice() : source.slice(0, cut);
// A view restored below the root still needs somewhere for Back to go.
if (
kept.length === 0 &&
currentView !== "main" &&
RESTORABLE_VIEWS.has(currentView)
) {
return ["main"];
// The cost of that is honest and worth naming: a context that writes one field
// and then reads a different, untouched one is still served that field's
// default. Nothing closes that here — what closes it for the background is
// that the background cannot reach this module at all, which build.js asserts
// against esbuild's metafile on every build (FORBIDDEN_INPUTS in
// script/lib/forbiddenBundleInputs.js, pinned by
// tests/buildForbiddenInputs.test.js).
let adopted = false;
// Every field whose pre-load value would be a plausible-looking default rather
// than the user's data. The view scratch fields are guarded too: currentView
// and viewStack are persisted, and a save that carried their pre-load values
// would overwrite a real stored stack with an empty one.
const GUARDED_FIELDS = new Set(PERSISTED_FIELDS.concat(["hasWallet"]));
class StateNotLoadedError extends Error {
constructor(field) {
super(
"state." +
field +
" was read before loadState(); this context has no profile" +
" loaded and must not be served DEFAULT_STATE",
);
this.name = "StateNotLoadedError";
}
return kept;
}
// Loud, not defaulted. The whole defect class this guard closes looks exactly
// like working code at the call site: the read succeeds, the value is
// well-formed, and it describes a wallet that is not the user's.
const state = new Proxy(rawState, {
get(target, prop, receiver) {
if (
!loaded &&
!adopted &&
typeof prop === "string" &&
GUARDED_FIELDS.has(prop)
) {
throw new StateNotLoadedError(prop);
}
return Reflect.get(target, prop, receiver);
},
set(target, prop, value, receiver) {
if (typeof prop === "string" && GUARDED_FIELDS.has(prop)) {
adopted = true;
}
return Reflect.set(target, prop, value, receiver);
},
});
// Return the network configuration for the currently selected network.
function currentNetwork() {
return networkById(state.networkId);
}
// Every field written to and read from the single "autistmask" storage key.
// hasWallet is deliberately excluded from the diffing/merge logic below —
// like loadState() does, it is always derived from `wallets`, never carried
// as an independent value.
const PERSISTED_FIELDS = Object.keys(DEFAULT_STATE)
.filter((key) => key !== "hasWallet")
.concat([
"currentView",
"selectedWallet",
"selectedAddress",
"selectedToken",
"viewData",
"viewStack",
]);
// Turn a raw stored (or missing) record into the full, defaulted shape
// loadState() used to assign directly onto `state`. Pulled out as a pure
// function so saveState() can apply it too: the fields THIS page did not
// change still have to come from storage in their loaded-and-normalized
// form, not as the raw bytes another page (or an old release) left there —
// otherwise a legacy shape a load has always self-healed in memory (a
// missing networkEndpoints map, an out-of-range flag) is dropped right back
// into storage unfixed every time the page that DID normalize it saves
// something unrelated, because that field's value never "changed" for that
// page to notice.
function normalizePersisted(saved) {
saved = saved || {};
const out = {};
out.wallets = saved.wallets || [];
// Derived, never trusted verbatim off storage — see loadState().
out.hasWallet = out.wallets.length > 0;
out.trackedTokens = saved.trackedTokens || [];
out.networkId = saved.networkId || DEFAULT_STATE.networkId;
out.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl;
out.blockscoutUrl = saved.blockscoutUrl || DEFAULT_STATE.blockscoutUrl;
// An actual object is required, not merely a truthy non-array: the code
// below and onChainSwitch() index and ASSIGN INTO this value, and
// assigning a property to a string or a number is a silent no-op in
// sloppy mode. Copied rather than referenced, nested pairs included, so
// normalizing never mutates the object a caller handed in.
const rawEndpoints =
typeof saved.networkEndpoints === "object" &&
saved.networkEndpoints !== null &&
!Array.isArray(saved.networkEndpoints)
? saved.networkEndpoints
: {};
out.networkEndpoints = {};
for (const netId of Object.keys(rawEndpoints)) {
out.networkEndpoints[netId] = { ...rawEndpoints[netId] };
}
// A profile written before this map existed carries exactly one pair of
// endpoints, belonging to whatever network it was last on. Adopt it as
// that network's remembered pair, so a custom endpoint set on the old
// build is not lost by the first switch away and back.
if (!out.networkEndpoints[out.networkId]) {
out.networkEndpoints[out.networkId] = {
rpcUrl: out.rpcUrl,
blockscoutUrl: out.blockscoutUrl,
};
}
out.lastBalanceRefresh = saved.lastBalanceRefresh || 0;
out.activeAddress = saved.activeAddress || null;
out.allowedSites =
saved.allowedSites && !Array.isArray(saved.allowedSites)
? saved.allowedSites
: {};
out.deniedSites =
saved.deniedSites && !Array.isArray(saved.deniedSites)
? saved.deniedSites
: {};
out.rememberSiteChoice =
saved.rememberSiteChoice !== undefined
? saved.rememberSiteChoice
: true;
out.showZeroBalanceTokens =
saved.showZeroBalanceTokens !== undefined
? saved.showZeroBalanceTokens
: true;
// A profile written before this setting existed has no key for it. It
// is a safety filter, so absent must load as on, not as undefined.
out.hideSpoofedSymbols =
saved.hideSpoofedSymbols !== undefined
? saved.hideSpoofedSymbols
: true;
out.hideLowHolderTokens =
saved.hideLowHolderTokens !== undefined
? saved.hideLowHolderTokens
: true;
out.hideFraudContracts =
saved.hideFraudContracts !== undefined
? saved.hideFraudContracts
: true;
out.hideDustTransactions =
saved.hideDustTransactions !== undefined
? saved.hideDustTransactions
: true;
out.dustThresholdGwei =
saved.dustThresholdGwei !== undefined
? saved.dustThresholdGwei
: 100000;
out.utcTimestamps =
saved.utcTimestamps !== undefined ? saved.utcTimestamps : false;
out.fraudContracts = saved.fraudContracts || [];
out.tokenHolderCache = saved.tokenHolderCache || {};
out.theme = saved.theme || "system";
out.debugMode = saved.debugMode !== undefined ? saved.debugMode : false;
out.currentView = saved.currentView || null;
out.selectedWallet =
saved.selectedWallet !== undefined ? saved.selectedWallet : null;
out.selectedAddress =
saved.selectedAddress !== undefined ? saved.selectedAddress : null;
out.selectedToken = saved.selectedToken || null;
out.viewData = saved.viewData || {};
out.viewStack = restorableStack(saved.viewStack, out.currentView);
return out;
}
// The persisted fields as they stood at the end of this page's last
// loadState() or saveState(). saveState() diffs the live state against this
// to find only the fields THIS page actually changed.
@@ -217,7 +127,7 @@ let baseline = null;
function snapshotPersisted() {
const out = {};
for (const key of PERSISTED_FIELDS) out[key] = state[key];
for (const key of PERSISTED_FIELDS) out[key] = rawState[key];
return out;
}
@@ -374,11 +284,10 @@ function mergeWallet(base, ours, theirs) {
}
// Merge one address's leaf fields (balance, ensName, tokenBalances, ...).
// tokenBalances is itself an array, but only backgroundRefresh() ever
// writes it and always wholesale (refreshBalances() in
// src/shared/balances.js), so there is no membership to reconcile within
// it — it is a leaf like balance or ensName, not a list with its own
// identity.
// tokenBalances is itself an array, but only a balance refresh ever writes
// it and always wholesale (refreshBalances() in src/shared/balances.js), so
// there is no membership to reconcile within it — it is a leaf like balance
// or ensName, not a list with its own identity.
function mergeAddress(base, ours, theirs) {
if (!base) return ours;
const merged = { ...theirs };
@@ -425,12 +334,12 @@ function mergeMapByKey(base, ours, theirs, mergeLeaf) {
}
// allowedSites/deniedSites: { [address]: [hostname, ...] }. The hostname
// list is itself membership, not a leaf — src/background/index.js pushes a
// newly approved/denied hostname onto it in place, and the Settings "revoke"
// button (src/popup/views/settings.js) filters a hostname out of it in
// place, from a different page. Merge it the same way wallets are merged:
// identity is the hostname itself, so a merged pair is always equal and
// mergeItem is a no-op pick.
// list is itself membership, not a leaf — the background appends a newly
// approved/denied hostname to it, and the Settings "revoke" button
// (src/popup/views/settings.js) filters a hostname out of it in place, from a
// different page. Merge it the same way wallets are merged: identity is the
// hostname itself, so a merged pair is always equal and mergeItem is a no-op
// pick.
function mergeHostnameList(base, ours, theirs) {
return mergeListByIdentity(
base,
@@ -445,14 +354,15 @@ function mergeSiteMap(base, ours, theirs) {
return mergeMapByKey(base, ours, theirs, mergeHostnameList);
}
// networkEndpoints: { [networkId]: {rpcUrl, blockscoutUrl} }. onChainSwitch()
// (src/shared/chainSwitch.js) writes state.networkEndpoints[networkId] in
// place before saving. No code path ever removes a key from this map, so the
// membership collision that matters for allowedSites/wallets (an add on one
// page racing a delete on another) can't happen here — but two pages
// switching to two different networks concurrently still race a whole-field
// diff the same way, so it gets the same per-key merge for the leaf edit
// case (e.g. Settings saving a custom RPC URL for the active network).
// networkEndpoints: { [networkId]: {rpcUrl, blockscoutUrl} }.
// applyChainSwitchFields() (src/shared/chainSwitchFields.js) writes
// networkEndpoints[networkId] in place before saving. No code path ever
// removes a key from this map, so the membership collision that matters for
// allowedSites/wallets (an add on one page racing a delete on another) can't
// happen here — but two pages switching to two different networks
// concurrently still race a whole-field diff the same way, so it gets the same
// per-key merge for the leaf edit case (e.g. Settings saving a custom RPC URL
// for the active network).
function mergeEndpointEntry(base, ours, theirs) {
if (!base) return ours;
const merged = { ...theirs };
@@ -468,12 +378,12 @@ function mergeNetworkEndpoints(base, ours, theirs) {
// Read-modify-write, merged per field, rather than one full-blob write.
//
// Every extension page (the toolbar popup, a dApp approval window, the
// background's backgroundRefresh()) holds its own in-memory `state`, loaded
// once, and showView() saves on every navigation. A full-blob write here
// clobbers whatever a second page had written since — including, in the
// worst case, an entire wallet and its encrypted secret with no attacker
// and no unusual input (see the issue this fixes).
// Every extension page (the toolbar popup, a dApp approval window) holds its
// own in-memory `state`, loaded once, and showView() saves on every
// navigation. A full-blob write here clobbers whatever a second page had
// written since — including, in the worst case, an entire wallet and its
// encrypted secret with no attacker and no unusual input (see the issue this
// fixes).
//
// Only the fields this page actually changed — those that differ from
// `baseline`, captured at the last loadState()/saveState() on this page —
@@ -482,28 +392,27 @@ function mergeNetworkEndpoints(base, ours, theirs) {
//
// `wallets` is merged structurally (mergeListByIdentity(), by wallet
// identity and then by address identity within each wallet), not as one
// whole field: backgroundRefresh() mutates wallets IN PLACE (addr.balance /
// whole field: a balance refresh mutates wallets IN PLACE (addr.balance /
// ensName / tokenBalances, via refreshBalances()), so a whole-field diff
// would mark all of `wallets` "changed" the moment any balance moved and
// write back background's own copy — loaded before its multi-second network
// write back that page's own copy — loaded before its multi-second network
// round trip — clobbering a wallet another page added, or resurrecting one
// another page deleted, in that window. Merging by identity lets
// background's leaf changes and another page's membership changes
// (add/delete a wallet or an address) apply independently instead of
// colliding as the same field.
// another page deleted, in that window. Merging by identity lets the leaf
// changes and another page's membership changes (add/delete a wallet or an
// address) apply independently instead of colliding as the same field.
//
// `allowedSites` and `deniedSites` get the same treatment (mergeSiteMap(),
// by address key and then by hostname within each address's list), for the
// identical reason: src/background/index.js pushes a newly
// approved/denied hostname onto them in place, and the Settings "revoke"
// button (src/popup/views/settings.js) filters one out in place, from a
// different page. A whole-field diff here doesn't just lose data, it is a
// security defect — a stale page's save can resurrect a just-revoked site
// permission, or silently wipe a permission just granted elsewhere.
// identical reason: the background appends a newly approved/denied hostname
// to them, and the Settings "revoke" button (src/popup/views/settings.js)
// filters one out in place, from a different page. A whole-field diff here
// doesn't just lose data, it is a security defect — a stale page's save can
// resurrect a just-revoked site permission, or silently wipe a permission just
// granted elsewhere.
//
// `networkEndpoints` gets the same treatment too (mergeNetworkEndpoints(),
// by network id), since onChainSwitch() writes into it in place; the value
// per key is a small leaf object with no membership of its own; see the
// by network id), since applyChainSwitchFields() writes into it in place; the
// value per key is a small leaf object with no membership of its own; see the
// comment at mergeEndpointEntry() for why the collision this closes is
// milder than the other two.
//
@@ -511,8 +420,8 @@ function mergeNetworkEndpoints(base, ours, theirs) {
// `trackedTokens`/`fraudContracts`/`viewStack` are arrays of scalars with no
// per-element identity to merge by; `tokenHolderCache` is a map shaped like
// the ones above, but nothing in src/ ever writes an entry into it — it is
// only ever reset wholesale to `{}` (onChainSwitch()) — so there is no
// in-place mutation for a whole-field diff to collide with; `viewData` is
// only ever reset wholesale to `{}` (applyChainSwitchFields()) — so there is
// no in-place mutation for a whole-field diff to collide with; `viewData` is
// this page's own UI scratch space, not data another page has any reason to
// share membership of.
//
@@ -539,7 +448,7 @@ async function saveStateOnce() {
const result = await storageGet("autistmask");
// Normalized, not raw: a field this page did not change still has to
// come from storage in its loaded (self-healed) shape. See
// normalizePersisted() above.
// normalizePersisted() in persistedState.js.
const fresh = normalizePersisted(result.autistmask);
const merged = { ...fresh };
@@ -578,7 +487,7 @@ async function saveStateOnce() {
// Derived from this page's own wallets, never adopted off the wire —
// see loadState(). Everything else this page did not change is left
// exactly as it stood; see the note above.
state.hasWallet = state.wallets.length > 0;
rawState.hasWallet = rawState.wallets.length > 0;
baseline = structuredClone(snapshotPersisted());
}
@@ -606,14 +515,21 @@ function saveState() {
async function loadState() {
const result = await storageGet("autistmask");
if (result.autistmask) {
Object.assign(state, normalizePersisted(result.autistmask));
Object.assign(rawState, normalizePersisted(result.autistmask));
}
// The point of comparison every saveState() on this page diffs against,
// whether storage had a profile or was empty. See PERSISTED_FIELDS above
// saveState() for why a reference here would be wrong.
// Whether storage had a profile or was empty, this context has now read
// it, and the defaults standing in for an empty profile are the right
// answer rather than a stand-in for one nobody looked for.
loaded = true;
// The point of comparison every saveState() on this page diffs against.
// See PERSISTED_FIELDS in persistedState.js for why a reference here
// would be wrong.
baseline = structuredClone(snapshotPersisted());
}
// Through the guarded proxy, not rawState: a caller asking which address is
// selected before anything was loaded gets the same loud failure it would get
// reading the fields itself.
function currentAddress() {
if (state.selectedWallet === null || state.selectedAddress === null) {
return null;
@@ -627,4 +543,5 @@ module.exports = {
loadState,
currentAddress,
currentNetwork,
StateNotLoadedError,
};