// 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, };