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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
69
src/shared/chainSwitchFields.js
Normal file
69
src/shared/chainSwitchFields.js
Normal 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 };
|
||||
@@ -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;
|
||||
|
||||
204
src/shared/persistedState.js
Normal file
204
src/shared/persistedState.js
Normal 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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user