// What the floor under each persisted field actually guarantees — as a table // that RUNS, one row per field. // // This file replaces a hand-written per-field justification in the header of // src/shared/stateSchema.js. That comment shipped a false claim in three // consecutive changes: every author wrote plausible prose about thirty fields, // every reviewer re-derived it by hand, and it kept being wrong in a different // place each time. The artifact was the problem. A claim nobody can execute is // worse than no claim, because it is believed. // // So the claim is a row here instead: // // KIND.REFUSED assertStateUsable() refuses the record outright. Proven by // stateProblem() naming a problem for every hostile value. // KIND.ENTRIES normalizePersisted() floors the container AND its entries. // Proven by holds() over the normalized value. // KIND.SCALAR normalizePersisted() floors it to one scalar type, or to a // fixed fallback. Proven the same way. // KIND.LOOSE `saved.x || default`, no type check at all. The claim is // that no structural dereference of it is reachable from a // stored record — which cannot be argued, only driven, so the // proof is a boot of the REAL popup entry point over a stored // record carrying the hostile value. // // Every row is driven through the boot regardless of kind, and a LOOSE row // must additionally prove it is loose: if someone floors the field and leaves // the row saying LOOSE, the "survives verbatim" assertion fails. A field added // to PERSISTED_FIELDS with no row fails the first test in the file. // // The three claims this replaced, all false, all caught here by construction: // rpcUrl reaching `new JsonRpcProvider()` (a synchronous throw, not a caught // request); viewData's ENTRIES being dereferenced by four restore branches // that gate on one truthy field each; and selectedWallet, where a stale // integer index is the SAFE case and `wallets["map"]` is the throwing one. const { PERSISTED_FIELDS, normalizePersisted, } = require("../src/shared/persistedState"); const { stateProblem } = require("../src/shared/stateSchema"); const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews"); const { bootPopup, cleanupPopup, unversionedValidProfile, ADDRESS, TOKEN_ADDRESS, } = require("./support/popupBoot"); const KIND = { REFUSED: "refused by the gate", ENTRIES: "container and entries type-checked", SCALAR: "scalar type-checked", LOOSE: "loosely floored; safety proven by driving the popup", }; const isText = (v) => typeof v === "string"; const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v); const isIndexOrNull = (v) => v === null || (Number.isInteger(v) && v >= 0); const isTextOrNull = (v) => v === null || (isText(v) && v !== ""); const everyEntry = (v, fn) => Array.isArray(v) && v.every(fn); // ------------------------------------------------------------------ the table // // `hostile` is values a stored record can carry that nothing in src/ ever // writes. Each one is driven through the floor AND through a real popup boot, // so keep the list short and pointed. `floorOnly` is extra values checked // against the floor alone, which is pure and free. const CONTRACT = [ { field: "wallets", kind: KIND.REFUSED, hostile: [42, "notastructure", { a: 1 }, [null], [{ addresses: 1 }]], }, { field: "networkId", kind: KIND.REFUSED, hostile: [42, "notanetwork", { a: 1 }, "__proto__"], }, { field: "trackedTokens", kind: KIND.ENTRIES, hostile: [42, "notalist", { a: 1 }], floorOnly: [[1, 2], [null], [{}], [[TOKEN_ADDRESS]]], holds: (v) => everyEntry(v, (t) => isRecord(t) && isText(t.address)), }, { field: "allowedSites", kind: KIND.ENTRIES, hostile: [42, "notarecord", { [ADDRESS]: "notalist" }], floorOnly: [ [ADDRESS], { [ADDRESS]: 42 }, { [ADDRESS]: [42, null, {}] }, JSON.parse('{"__proto__":["evil.invalid"]}'), ], holds: siteMapHolds, }, { field: "deniedSites", kind: KIND.ENTRIES, hostile: [42, "notarecord", { [ADDRESS]: "notalist" }], floorOnly: [ [ADDRESS], { [ADDRESS]: 42 }, { [ADDRESS]: [42, null, {}] }, JSON.parse('{"__proto__":["evil.invalid"]}'), ], holds: siteMapHolds, }, { field: "fraudContracts", kind: KIND.ENTRIES, hostile: [42, "notalist", { a: 1 }], floorOnly: [[42], [null], [{}], [[TOKEN_ADDRESS]]], holds: (v) => everyEntry(v, isText), }, { field: "viewStack", kind: KIND.ENTRIES, hostile: [42, "notalist", ["main", "show-phrase", "settings"]], floorOnly: [[1, 2], [null], [{}], ["export-privkey"]], // Truncated at the first entry the popup will not reopen onto, rather // than filtered: every surviving entry's Back target has to stay the // one it had. restorableStack() may also substitute ["main"] under a // view restored below the root, so this is the one ENTRIES field whose // result is not always a subset of what was stored. holds: (v) => everyEntry(v, (e) => RESTORABLE_VIEWS.has(e)), }, { field: "networkEndpoints", kind: KIND.ENTRIES, hostile: [42, "notarecord", { mainnet: "notapair" }], floorOnly: [ [1, 2], { mainnet: { rpcUrl: 42, blockscoutUrl: {} } }, { mainnet: { rpcUrl: "", blockscoutUrl: [] } }, { sepolia: 42 }, ], // Entries are coerced rather than dropped: an unknown network id is // KEPT, so a profile that has been on a build with more networks does // not lose their endpoints here. What is floored is the two URL fields // inside the pair, which applyChainSwitchFields() assigns straight onto // s.rpcUrl / s.blockscoutUrl on the next switch. holds: (v) => isRecord(v) && Object.keys(v).every((id) => { const pair = v[id]; return ( isRecord(pair) && (pair.rpcUrl === undefined || (isText(pair.rpcUrl) && pair.rpcUrl !== "")) && (pair.blockscoutUrl === undefined || (isText(pair.blockscoutUrl) && pair.blockscoutUrl !== "")) ); }), }, { field: "rpcUrl", kind: KIND.SCALAR, hostile: [42, true, { a: 1 }], floorOnly: [[], "", null], holds: (v) => isText(v) && v !== "", // The claim this row replaced said a bad value "fails the request on a // path that already catches". It does not: getProvider() hands rpcUrl // to `new JsonRpcProvider()`, which throws SYNCHRONOUSLY, from two call // sites outside any try — and a stored `currentView: "wait-tx"` reaches // one of them through restoreView(). So the row proves the claim // against the real constructor rather than describing it. alsoProven: (normalized, hostile) => { // requireActual: bootPopup() mocks this module out for the boots // above, and a mocked getProvider() would prove nothing at all // about the constructor this row is a claim about. const { getProvider } = jest.requireActual( "../src/shared/balances", ); expect(() => getProvider(hostile, "mainnet")).toThrow(); const provider = getProvider(normalized, "mainnet"); expect(provider).toBeTruthy(); provider.destroy(); }, }, { field: "blockscoutUrl", kind: KIND.SCALAR, hostile: [42, true, { a: 1 }], floorOnly: [[], "", null], holds: (v) => isText(v) && v !== "", }, { field: "activeAddress", kind: KIND.SCALAR, hostile: [42, true, { a: 1 }], floorOnly: [[ADDRESS], ""], holds: isTextOrNull, }, { field: "selectedToken", kind: KIND.SCALAR, hostile: [42, true, { a: 1 }], floorOnly: [[TOKEN_ADDRESS], ""], holds: isTextOrNull, }, { field: "selectedWallet", kind: KIND.SCALAR, // The prototype members are the whole point: `wallets["map"]` is // TRUTHY, so hasValidAddress()'s `&&` does not short-circuit and // `.addresses[…]` throws. A stale INTEGER is the safe case. hostile: ["map", "__proto__", { a: 1 }], floorOnly: ["length", "constructor", "toString", "0", -1, 1.5, true], holds: isIndexOrNull, }, { field: "selectedAddress", kind: KIND.SCALAR, hostile: ["map", "__proto__", { a: 1 }], floorOnly: ["length", "constructor", "toString", "0", -1, 1.5, true], holds: isIndexOrNull, }, { field: "currentView", kind: KIND.LOOSE, // Compared, and concatenated into the debug banner's textContent // (src/popup/views/helpers.js) with no gate in front of it, which // coerces. Nothing renders FROM it without RESTORABLE_VIEWS.has() // first, and Set.has() answers false for any value. hostile: [42, "no-such-view", { a: 1 }], }, { field: "viewData", kind: KIND.LOOSE, // The container is taken verbatim; what makes its ENTRIES safe is the // per-branch guard in src/popup/viewRouter.js. Driven over every // restorable view in "a malformed viewData" below, which is the proof // this row rests on. hostile: [42, "notarecord", { a: 1 }], }, { field: "lastBalanceRefresh", kind: KIND.LOOSE, // Arithmetic only: `now - (s.lastBalanceRefresh || 0)` compares false // for a non-number and forces a refresh. hostile: [true, "notatime", { a: 1 }], }, { field: "tokenHolderCache", kind: KIND.LOOSE, // Nothing DEREFERENCES it structurally. It is read by the // field-agnostic snapshotPersisted()/deepEqual() in // src/shared/state.js, which are safe for any value, and otherwise // only reset wholesale in src/shared/chainSwitchFields.js. hostile: [42, "notarecord", [1, 2]], }, { field: "theme", kind: KIND.LOOSE, // Compared against "dark"/"light" in applyTheme() and otherwise falls // to the system branch; assigned into an input .value, which coerces. hostile: [42, "chartreuse", { a: 1 }], }, { field: "dustThresholdGwei", kind: KIND.LOOSE, hostile: ["notanumber", true, { a: 1 }], }, ...[ "rememberSiteChoice", "showZeroBalanceTokens", "hideSpoofedSymbols", "hideLowHolderTokens", "hideFraudContracts", "hideDustTransactions", "utcTimestamps", "debugMode", ].map((field) => ({ field, kind: KIND.LOOSE, // A flag: only ever tested for truthiness, and written back verbatim. hostile: [42, "notabool", { a: 1 }], })), ]; function siteMapHolds(v) { return ( isRecord(v) && Object.getPrototypeOf(v) === Object.prototype && !Object.prototype.hasOwnProperty.call(v, "__proto__") && Object.keys(v).every((key) => everyEntry(v[key], isText)) ); } afterEach(() => { cleanupPopup(); }); // -------------------------------------------------------------- exhaustive describe("the contract covers the record", () => { test("every persisted field has exactly one row, and no row invents one", () => { const rows = CONTRACT.map((row) => row.field); expect([...rows].sort()).toEqual([...PERSISTED_FIELDS].sort()); }); test("every row declares a kind this file knows how to prove", () => { const kinds = Object.values(KIND); for (const row of CONTRACT) { expect(kinds).toContain(row.kind); expect(row.hostile.length).toBeGreaterThan(0); } }); }); // ------------------------------------------------------------- the floors function profileWith(field, value) { return unversionedValidProfile({ [field]: value }); } describe("the floor each row claims", () => { for (const row of CONTRACT) { const values = [...row.hostile, ...(row.floorOnly || [])]; if (row.kind === KIND.REFUSED) { test(`${row.field}: the gate refuses it`, () => { for (const value of values) { expect( typeof stateProblem(profileWith(row.field, value)), ).toBe("string"); } }); continue; } test(`${row.field}: ${row.kind}`, () => { for (const value of values) { const out = normalizePersisted(profileWith(row.field, value)); if (row.kind === KIND.LOOSE) { // The claim IS that there is no floor. A field that grows // one has to move to another kind rather than keep a row // saying its readers are what make it safe. continue; } expect({ value: value, holds: row.holds(out[row.field]), }).toEqual({ value: value, holds: true }); } }); if (row.kind === KIND.LOOSE) { test(`${row.field}: is genuinely unfloored`, () => { const survived = values.some((value) => { const out = normalizePersisted( profileWith(row.field, value), ); return ( JSON.stringify(out[row.field]) === JSON.stringify(value) ); }); expect(survived).toBe(true); }); } } }); // --------------------------------------------- driving the real popup boot // A booted popup is healthy when nothing threw out of init() and something is // on screen. A throw out of restoreView() is neither: init() does not guard it, // so the rest of popup init never runs and the user gets a popup with no view, // no message and no control on it. async function bootHealth(profile) { const env = await bootPopup(profile); return { errors: env.pageErrors, blank: env.visibleViews().length === 0, }; } const HEALTHY = { errors: [], blank: false }; describe("a hostile value for one field, through the real popup", () => { for (const row of CONTRACT) { for (const value of row.hostile) { test(`${row.field} = ${JSON.stringify(value)}`, async () => { await expect( bootHealth(profileWith(row.field, value)), ).resolves.toEqual(HEALTHY); }); } } }); describe("a row's extra proof against the real reader", () => { for (const row of CONTRACT) { if (!row.alsoProven) continue; test(row.field, () => { for (const value of row.hostile) { const out = normalizePersisted(profileWith(row.field, value)); row.alsoProven(out[row.field], value); } }); } }); // ------------------------------------------------- viewData, entry by entry // The views that read viewData. const DATA_VIEWS = [ "confirm-tx", "transaction", "wait-tx", "success-tx", "error-tx", ]; // Each record below PASSES the gate of the branch it names, and then carries a // value that branch's renderer dereferences. `views` is where it is driven from // — the whole set for a value that is not a record at all, and otherwise the // branch it targets, since the cross-view case is covered by EVERY_GATE below. const HOSTILE_VIEW_DATA = [ { data: 42, views: DATA_VIEWS }, { data: "notarecord", views: DATA_VIEWS }, { data: [1, 2], views: DATA_VIEWS }, // success-tx passes on `data.hash`, and renderSuccess() then calls // toAddressHtml(d.to) -> addressTitle() -> address.toLowerCase(). { data: { hash: "0x1" }, views: ["success-tx"] }, { data: { hash: "0x1", to: 42 }, views: ["success-tx"] }, { data: { hash: "0x1", to: ADDRESS, decoded: { details: 7 } }, views: ["success-tx"], }, { data: { hash: "0x1", to: ADDRESS, decoded: { details: [{ address: 42 }] }, }, views: ["success-tx"], }, // error-tx passes on `data.message`, same dereference. { data: { message: "boom" }, views: ["error-tx"] }, { data: { message: "boom", to: 42 }, views: ["error-tx"] }, // transaction passes on `data.tx`. { data: { tx: { hash: "0x1" } }, views: ["transaction"] }, { data: { tx: { hash: "0x1", from: ADDRESS, to: ADDRESS, contractAddress: 42, }, }, views: ["transaction"], }, // confirm-tx passes on `data.pendingTx`. { data: { pendingTx: { amount: "1" } }, views: ["confirm-tx"] }, { data: { pendingTx: { token: 42, from: ADDRESS, to: ADDRESS, amount: "1" }, }, views: ["confirm-tx"], }, // wait-tx passes on `pendingWait.hash`; restoreWait() has checked the // fields below it since it was written, and this is the regression guard. { data: { pendingWait: { hash: "0x1", txInfo: { to: 42, amount: "1" } } }, views: ["wait-tx"], }, ]; function restoringOnto(view, extra) { return unversionedValidProfile({ currentView: view, selectedWallet: 0, selectedAddress: 0, selectedToken: TOKEN_ADDRESS, viewStack: ["main"], ...extra, }); } describe("a malformed viewData restoring onto", () => { for (const { data, views } of HOSTILE_VIEW_DATA) { for (const view of views) { test(`${view}: ${JSON.stringify(data)}`, async () => { await expect( bootHealth(restoringOnto(view, { viewData: data })), ).resolves.toEqual(HEALTHY); }); } } // Every restorable view, against one record that passes every branch's // gate at once: a branch a view does not read must stay one it does not // read, and each renderer must survive the fields another branch left. const EVERY_GATE = { hash: "0x1", message: "boom", tx: { hash: "0x1" }, pendingTx: { amount: "1" }, pendingWait: { hash: "0x1" }, }; for (const view of RESTORABLE_VIEWS) { test(`${view}: a record passing every branch's gate at once`, async () => { await expect( bootHealth(restoringOnto(view, { viewData: EVERY_GATE })), ).resolves.toEqual(HEALTHY); }); } }); // --------------------------------------- selectedWallet / selectedAddress // `wallets` is a real Array, so a selectedWallet naming an Array.prototype or // Object.prototype member is TRUTHY: hasValidAddress()'s `&&` does not // short-circuit, `.addresses` is undefined, and the index access throws out of // restoreView(). A stale INTEGER is falsy-or-in-range and safe — the opposite // way round from how this pair was described. const HOSTILE_INDEX = [ { selectedWallet: "map", selectedAddress: 0 }, { selectedWallet: "length", selectedAddress: 0 }, { selectedWallet: "__proto__", selectedAddress: 0 }, { selectedWallet: "constructor", selectedAddress: 0 }, { selectedWallet: 0, selectedAddress: "map" }, { selectedWallet: 5, selectedAddress: 0 }, ]; const INDEX_VIEWS = [ "address", "address-token", "receive", "transaction", "confirm-tx", ]; describe("a malformed wallet or address index restoring onto", () => { const WELL_FORMED_DATA = { tx: { hash: "0x1", from: ADDRESS, to: ADDRESS }, pendingTx: { token: "ETH", from: ADDRESS, to: ADDRESS, amount: "1", balance: "2", }, }; for (const view of INDEX_VIEWS) { for (const indices of HOSTILE_INDEX) { test(`${view}: ${JSON.stringify(indices)}`, async () => { await expect( bootHealth( restoringOnto(view, { ...indices, viewData: WELL_FORMED_DATA, }), ), ).resolves.toEqual(HEALTHY); }); } } });