// 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", ); } // 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; 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)) { // defineProperty, not assignment: a stored map with an own // "__proto__" key — which JSON can carry and assignment treats as the // prototype setter — would otherwise replace this object's prototype // and record no entry at all. 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. Object.defineProperty(out.networkEndpoints, netId, { value: { ...rawEndpoints[netId] }, writable: true, enumerable: true, configurable: true, }); } // 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 = 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, };