// 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"); const { isKnownNetworkId } = require("./networks"); const { STATE_SCHEMA_VERSION } = require("./stateSchema"); // Dependency-free constant module. It lives under src/shared/ rather than // src/popup/ precisely because this module is in the background bundle: a // popup-path module reached from the worker is the shape the prohibition in // script/lib/forbiddenBundleInputs.js exists to keep out, even when the // particular module is harmless. const { RESTORABLE_VIEWS } = require("./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. schemaVersion is excluded for the // same reason and is absent from DEFAULT_STATE for it: it describes the // record rather than being part of it, and every write stamps the current // value rather than diffing whatever was read. const PERSISTED_FIELDS = Object.keys(DEFAULT_STATE) .filter((key) => key !== "hasWallet") .concat([ "currentView", "selectedWallet", "selectedAddress", "selectedToken", "viewData", "viewStack", ]); function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } // A list of token references, as everything downstream dereferences them: // `t.address.toLowerCase()`, with no guard of its own (src/shared/balances.js, // src/popup/views/helpers.js, and every view that shows a balance line). // // Both the container AND the entries, because they are separate defects. A // container check alone leaves a well-formed list of malformed entries walking // through to a dereference one level below the check, which is the same blank // popup: `[1, 2]` and `[{}]` are lists. // // A malformed entry is DROPPED rather than repaired: a token reference with no // address identifies nothing, so there is no value to repair it to, and the // alternative — refusing the whole record — sends a user whose wallets are // perfectly readable to an export-or-erase screen over a token list. An entry // that is a record with a text address is kept verbatim, extra fields and all. // // Verbatim is load-bearing for the fields BESIDE the address. A tokenBalances // entry carries `decimals: null` and `balance: null` when nothing knows the // token's scale (src/shared/balances.js, // https://git.eeqj.de/sneak/AutistMask/issues/349), and those nulls are the // record that the value is unknown. Only `address` decides whether an entry // survives, so an unknown-scale holding is kept — flooring a null here to some // default would put the guess back one layer down from where it was removed. function tokenRefs(value) { if (!Array.isArray(value)) return []; return value.filter( (entry) => isRecord(entry) && typeof entry.address === "string", ); } // A list of strings, for the fields whose entries are dereferenced as text: // fraudContracts (`a.toLowerCase()` in src/popup/views/send.js and // src/shared/transactions.js) and each address's hostname list in the site maps // below (`h !== host` filters, `list.includes(hostname)` in the background). // // Same rule as tokenRefs(), for the same reason: the container AND the entries, // with a malformed entry DROPPED rather than repaired. A number in a hostname // list names no site and a number in fraudContracts names no contract, so there // is nothing to repair either to, and the empty list is a legitimate value that // survives. The result is a fresh array of primitives, so it shares no // structure with `saved`. function textList(value) { if (!Array.isArray(value)) return []; return value.filter((entry) => typeof entry === "string"); } // allowedSites / deniedSites: { [address]: [hostname, ...] }. // // The container check these had (truthy and not an array) is not the floor: // `{"0xabc…": "notalist"}` IS a non-array object, and the dereference is one // level below it. saveState() merges these maps per key and then per hostname // WITHIN each key, so a stored value that is not a list reaches `base.map()` in // mergeListByIdentity() (src/shared/state.js) and throws — after the popup has // rendered, which is why every save from then on failed while the UI looked // healthy (https://git.eeqj.de/sneak/AutistMask/issues/362). The Settings // revoke button (`list.filter()`), and the background's // `allowed.includes(hostname)` gate, dereference it the same way; on that last // one a stored string would also answer a SUBSTRING match, so a corrupt map // could widen a site permission rather than merely throw. // // An address key whose value is not a list of hostnames is dropped entirely: it // grants and denies nothing, and dropping it fails closed. A stored own // "__proto__" key — which JSON can carry — is dropped for the same reason: it // can never be a wallet address, so it grants nothing either, and keeping it // only keeps a value that saveState()'s merge would hand to the prototype // setter on the next write. Keys are written with defineProperty so that no key // reaching this function can consult a setter at all, whatever the rule above // it becomes; mergeMapByKey() in src/shared/state.js writes the same way. function siteMap(value) { const out = {}; if (!isRecord(value)) return out; for (const address of Object.keys(value)) { if (address === "__proto__") continue; const hostnames = textList(value[address]); if (hostnames.length === 0) continue; defineOwn(out, address, hostnames); } return out; } // An endpoint URL: non-empty text, or the fallback. function url(value, fallback) { return typeof value === "string" && value !== "" ? value : fallback; } // One remembered endpoint pair out of networkEndpoints, floored on the two // fields applyChainSwitchFields() (src/shared/chainSwitchFields.js) assigns // STRAIGHT ONTO s.rpcUrl / s.blockscoutUrl on the next chain switch: flooring // the live fields alone would leave a non-string sitting one switch away from // them. A field that is not text is deleted rather than replaced, so the // switch falls through its own `|| net.defaultRpcUrl`. Anything else the pair // carries is kept: a profile that has been on a build storing more per-network // fields must not lose them by passing through this one. function endpointPair(value) { const pair = { ...(isRecord(value) ? value : {}) }; for (const field of ["rpcUrl", "blockscoutUrl"]) { if (typeof pair[field] !== "string" || pair[field] === "") { delete pair[field]; } } return pair; } // A list index into wallets / a wallet's addresses: a non-negative integer, or // null for "nothing selected". // // hasValidAddress() (src/popup/viewRouter.js) guards the restore path with // `state.wallets[state.selectedWallet] && …addresses[state.selectedAddress]`, // which is safe for a stale INTEGER — out of range is undefined, and the `&&` // short-circuits — and NOT safe for a string naming an Array.prototype member. // `wallets["map"]` is truthy, so the guard does not short-circuit and // `.addresses[…]` throws out of restoreView(): the dead popup. "length", // "constructor" and "__proto__" answer the same way, and // src/popup/views/confirmTx.js dereferences selectedWallet behind no guard at // all. function listIndex(value) { return Number.isInteger(value) && value >= 0 ? value : null; } // Write `key` as an own data property, never through a setter. Plain // assignment of "__proto__" replaces the object's prototype and records no // entry; every map built from stored keys goes through this. function defineOwn(obj, key, value) { Object.defineProperty(obj, key, { value: value, writable: true, enumerable: true, configurable: true, }); } // 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 = {}; // Every write goes out at the current version. That IS the migration for // the unversioned records every install in the field holds: version 1 is // the shape that shipped unversioned, so a record that validated is // carried forward simply by being stamped. A record this build does NOT // understand never reaches here — assertStateUsable() refuses it on the // read path first (src/shared/stateSchema.js). out.schemaVersion = STATE_SCHEMA_VERSION; out.wallets = structuredClone(saved.wallets || []); // Derived, never trusted verbatim off storage — see loadState(). out.hasWallet = out.wallets.length > 0; // Each address's token holdings, floored to a list of token records on the // detached copy above. Every reader iterates it behind a `|| []` that only // covers an ABSENT value, and then dereferences `t.address.toLowerCase()` // and `t.balance` — so a stored string iterates as characters, a number // throws on the iterator, and a null entry throws on the field. // // This field specifically, because refreshBalances() writes it WHOLESALE // rather than merging into it: a write that only partly lands is the live // cause https://git.eeqj.de/sneak/AutistMask/issues/311 names, and this is // where it lands. The wallet list itself is the gate's (stateSchema.js); // what is below an address record is not, and gets floored here. if (Array.isArray(out.wallets)) { for (const wallet of out.wallets) { if (!isRecord(wallet) || !Array.isArray(wallet.addresses)) continue; for (const addr of wallet.addresses) { if (!isRecord(addr)) continue; addr.tokenBalances = tokenRefs(addr.tokenBalances); } } } // An actual list of token records is required, not merely a truthy value // and not merely a list: everything downstream iterates this and // dereferences `token.address`, so a stored string or object walks through // a `|| []`, and a list of numbers walks through an Array.isArray(), and // both throw on the first read — the blank popup from the issue, for a // profile whose wallets are perfectly fine. An empty list is a legitimate // value and survives. out.trackedTokens = structuredClone(tokenRefs(saved.trackedTokens)); // The loud refusal for an unknown id is assertStateUsable(); this is the // floor under it. networkId is an object KEY into networkEndpoints below, // so a value that is not a network in networks.js must never get that far // — "__proto__" would set the map's prototype instead of an own key, and // the user's endpoint would silently not be recorded. out.networkId = isKnownNetworkId(saved.networkId) ? saved.networkId : DEFAULT_STATE.networkId; // Non-empty text or the default, never anything else. getProvider() // (src/shared/balances.js) hands rpcUrl straight to `new // JsonRpcProvider()`, which throws SYNCHRONOUSLY for a value that is not a // string — out of src/popup/views/txStatus.js and src/popup/views/ // addWallet.js, neither of which is inside a try, and the first of which a // stored `currentView: "wait-tx"` reaches through restoreView(). It is a // scalar, so the type check is the whole fix. out.rpcUrl = url(saved.rpcUrl, DEFAULT_STATE.rpcUrl); out.blockscoutUrl = url(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)) { // Keys other than the known network ids are kept rather than dropped, // so a profile that has been on a build with more networks does not // lose their endpoints by passing through this one. That is why an own // "__proto__" key survives here where siteMap() drops it, and why the // write has to go through defineOwn(). defineOwn( out.networkEndpoints, netId, endpointPair(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; // A non-empty address, or null, never anything else: this is passed to // address.slice() and compared against stored addresses, so a stored // number or object walks through a `|| null` and throws on the first // render. The empty string is text but it is not an address, and it must // become null rather than survive: init() auto-selects the first address // only on a STRICT null, so a stored "" would leave the popup with no // address ever selected. Nothing in src/ writes one, and this keeps the // behaviour the `|| null` this check replaced already had. out.activeAddress = typeof saved.activeAddress === "string" && saved.activeAddress !== "" ? saved.activeAddress : null; out.allowedSites = siteMap(saved.allowedSites); out.deniedSites = siteMap(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; // A list of contract addresses, floored the same way: send.js builds its // fraud set as `(state.fraudContracts || []).map((a) => a.toLowerCase())` // and filterTransactions() maps the same list through normalizeAddress(), // so a stored string walks through the `|| []` and a stored number walks // through an Array.isArray(). out.fraudContracts = textList(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 = listIndex(saved.selectedWallet); out.selectedAddress = listIndex(saved.selectedAddress); // "ETH", or a contract address, or null — never anything else. The popup // restores onto "address-token" behind a truthiness check on this field and // then dereferences it as text (`tokenId.toLowerCase()` in // src/popup/views/addressToken.js, `state.selectedToken.toLowerCase()` in // src/popup/views/receive.js), so a stored number is truthy, passes the // restore gate, and throws on the screen it restores onto. Found by the // sweep for this same defect class in // https://git.eeqj.de/sneak/AutistMask/issues/362; floored to null, which // is what the restore gate already treats as "nothing selected". The empty // string was already falsy here and stays null. out.selectedToken = typeof saved.selectedToken === "string" && saved.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, };