fetchTokenBalances() did parseInt(item.token.decimals || "18", 10) before writing to state.wallets[].addresses[].tokenBalances[].decimals, so a token whose decimals() reverts -- one the block explorer reports no scale for -- was stored with a fabricated 18 that no reader could tell from a real one. That is upstream of a rule already merged. #306 made the ERC-20 approval amount line resolve the real scale or refuse to format, and #340 extended it to the swap lines; both read this stored value as an authoritative source, so the guess walked straight past refusals that were intact and simply never fired. A 1,000-unit approval of such a token rendered 0.000000001 on the one screen whose job is to state what is being authorized. The stored value is now the explorer's own answer or null, never a default. Both approval paths reach unknownDecimalsAmount() on a null, using the refusal that was already there. The history list's token transfers carried the same || "18" and now state exact base units with the scale unknown rather than a quantity at a guessed one. A holding whose scale nothing knows has no quantity either, so its balance is stored as null -- unknown, never zero -- and the balance list, the address USD total, the Send screen and the confirmation screen each say so rather than printing 0.0000 for money that is really there. The zero-balance filter moved onto the base-unit integer, where it needs no scale at all. The bundled token list and the user's tracked tokens already outrank the explorer, so a token either of them knows still displays its real quantity when the explorer's entry omits decimals; only what none of the three knows is unknown. Which makes the stored field the explorer's answer alone, and NOT the scale a screen renders at. Those are two questions, and every screen that needs the second one asks resolveTokenDecimals(). The Send screen did not: it read tokenBalances[].decimals raw and carried it onto the pending transaction, so a bundled or tracked token whose explorer row omits decimals reached displayedDecimals(null) inside estimateGas(). That throws, is caught as an unavailable fee, and disables Send behind "The network fee could not be estimated ... Please go back and try again" -- untrue, unactionable, and for a token such as WETH whose scale was never in doubt. The balance and the amount on the same screen were correct throughout, and validateTransfer() had nothing to object to, so nothing named the real reason. Before this change the fabricated 18 happened to be that token's real scale and the send completed, so this is a capability regression and not an inherited one. Send now resolves the scale through resolveTokenDecimals(), with no fallback. The two resolutions are deliberately not identical, and where they differ the balance follows the scale. balances.js resolves without wallets, because it is formatting one explorer row during a fetch that is about to replace the very state it would be consulting; its explorer leg is therefore that row's own value. send.js resolves with wallets, which adds explorerDecimals()'s cross-address check, so a contract two addresses report different scales for answers null rather than picking one -- a check that must apply to a value which goes on to encode a transfer. For a token neither bundled nor tracked whose explorer rows disagree, that leaves a stored quantity computed at a scale Send has just refused. Stating it would leave validateTransfer() checking the amount against a number the wallet does not vouch for, and, since the unknown-balance path is gated on the balance rather than on the scale, would again leave the fee-estimate failure as the only thing on the confirmation screen. So Send withdraws the stored quantity along with the scale: an unknown scale is an unknown balance. Only a stored quantity is withdrawn -- the "0" for a token with no row at all is an absence of holdings, which is true at every scale. The uint8 check is one shared toDecimals() rather than three copies of it, and it answers 0 for a real scale of zero: || "18" collapsed that to eighteen, the falsy-collapse trap of #246. The reader half is asserted, not just the writer half. Each of the six sites that now distinguishes an unknown quantity from a zero one -- balanceLine(), balanceLinesForAddress(), addressHoldsFunds(), getAddressValue()'s partial flag, the Send balance line and the confirmation screen's balance and insufficient-balance wording -- is tested on the PAIR, because an assertion about null alone still passes on a build that renders both as zero. The Send and confirmation cases run the real explorer response through the real fetcher, the real review handler and the real confirmation screen, so they show which of the two scale questions each screen is asking, including a two-address fixture whose explorer rows report 6 and 18 for one contract. Existing installs hold 18s that cannot be told apart retroactively -- that is the defect, and no migration can undo it. They display exactly as they do today until the next balance refresh, which rewrites tokenBalances wholesale and needs no user action. The schema version is not bumped: version 1 records stay valid and are read exactly as before. No || 18 or ?? 18 fallback remains anywhere in src/. The literal 18s that do remain are real data rather than defaults: 432 per-token decimals: 18 entries in the bundled src/shared/tokenList.js, and, outside that file, only native ETH's protocol-defined scale in src/shared/uniswap.js and the fixed-point comparison scale in src/shared/txValidation.js.
311 lines
15 KiB
JavaScript
311 lines
15 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",
|
|
);
|
|
}
|
|
|
|
// 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,
|
|
};
|