A stored allowedSites whose value was not a list rendered a working popup and then made every subsequent save fail silently, so the user operated a wallet that persisted nothing -- worse than a blank popup, which is at least visibly broken. fraudContracts and selectedToken had the same shape: a container floored by truthiness or not at all, while its entries were dereferenced. Entries are now floored as well as containers, following the idiom #311 established, and a failed save raises a persistent banner instead of vanishing into a swallowed rejection. The per-field justifications that used to live in a hand-written header are replaced by a contract test that drives each field's hostile and falsy values through a real popup boot, so a claim about a field answers to the code rather than to prose. Its guarantee is stated narrowly and deliberately: no structural dereference on the code paths a wholly-corrupted profile takes, which is not every path a stored record takes. The paths it does not drive are named where the claim is made, and are tracked in #379.
426 lines
21 KiB
JavaScript
426 lines
21 KiB
JavaScript
// 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,
|
|
};
|