harden: stop the background reading the shared state singleton, and enforce it at build time (closes #324)
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:
+16
-49
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user