// 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, ONTO EVERY RESTORABLE // VIEW. Home is not where this class of defect lives. // // Every row is driven through a boot regardless of kind, but only a LOOSE row // (or a row that sets `alsoSweep`) is swept across the restore path: that is // what declaring LOOSE costs. ENTRIES and SCALAR rows are proven by their // holds() instead, because a floored value is not hostile by the time a // renderer sees it. A LOOSE row must additionally prove it is loose: if // someone floors the field — even partially — 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 sweep is what makes a LOOSE row falsifiable, so read how it is driven // before trusting it. A row the ROUTER reads (`routes`) gets its own boot per // view, because a hostile value in it legitimately changes which view renders. // Every other swept field is corrupted on the SAME boot, one boot per view per // hostile slot, and that boot has to land on the view it stored — so a field // that does move the routing cannot hide in the crowd, and a field that is // dereferenced by any renderer reachable from a stored record turns this file // red. Booting each of them separately would be about six hundred boots and // half a minute; this is thirty-three. // // 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); // A row is SWEPT — driven onto every restorable view rather than only onto // Home — when its claim is that no restore path dereferences the field. That // is what LOOSE means. The two index rows opt in with `alsoSweep` although // they are floored, because the restore path is precisely why they gained a // floor and the sweep is the regression guard on it. const swept = (row) => row.kind === KIND.LOOSE || Boolean(row.alsoSweep); // ------------------------------------------------------------------ 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 — // and, for a swept row, through one boot per restorable view — so keep the // list short and pointed. `floorOnly` is extra values checked against the // floor alone, which is pure and free. `hostileRestore` is extra values driven // through the restore path only, for a value that means nothing until a // particular branch's gate has let it past. 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, // SCALAR, and swept anyway: the restore path is precisely why this // field gained a floor, so the sweep is the regression guard on it. alsoSweep: true, routes: true, // A stale INTEGER index, which reaches the restore path by a different // route from the prototype members above — falsy or out of range // rather than truthy — and has to keep being the safe case. hostileRestore: [{ value: "length" }, { value: 5 }], }, { field: "selectedAddress", kind: KIND.SCALAR, hostile: ["map", "__proto__", { a: 1 }], floorOnly: ["length", "constructor", "toString", "0", -1, 1.5, true], holds: isIndexOrNull, alsoSweep: true, routes: true, hostileRestore: [{ value: 5 }], }, { field: "currentView", kind: KIND.LOOSE, routes: true, // 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, routes: true, // The container is taken verbatim; what makes its ENTRIES safe is the // per-branch guard in src/popup/viewRouter.js. The sweep drives the // container shapes below onto every restorable view; hostileRestore // adds the records that PASS a branch's gate and then hand its // renderer something it dereferences, which is where the entries are // actually decided. hostile: [42, "notarecord", { a: 1 }, [1, 2]], hostileRestore: [ // success-tx passes on `data.hash`, and renderSuccess() then calls // toAddressHtml(d.to) -> addressTitle() -> address.toLowerCase(). { value: { hash: "0x1" }, views: ["success-tx"] }, { value: { hash: "0x1", to: 42 }, views: ["success-tx"] }, { value: { hash: "0x1", to: ADDRESS, decoded: { details: 7 } }, views: ["success-tx"], }, { value: { hash: "0x1", to: ADDRESS, decoded: { details: [{ address: 42 }] }, }, views: ["success-tx"], }, // error-tx passes on `data.message`, same dereference. { value: { message: "boom" }, views: ["error-tx"] }, { value: { message: "boom", to: 42 }, views: ["error-tx"] }, // transaction passes on `data.tx`. { value: { tx: { hash: "0x1" } }, views: ["transaction"] }, { value: { tx: { hash: "0x1", from: ADDRESS, to: ADDRESS, contractAddress: 42, }, }, views: ["transaction"], }, // confirm-tx passes on `data.pendingTx`. { value: { pendingTx: { amount: "1" } }, views: ["confirm-tx"] }, { value: { 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. { value: { pendingWait: { hash: "0x1", txInfo: { to: 42, amount: "1" }, }, }, views: ["wait-tx"], }, // A record that passes EVERY branch's gate at once, driven onto // every restorable view: a branch a view does not read must stay // one it does not read, and each renderer must survive the fields // another branch left behind. { value: { hash: "0x1", message: "boom", tx: { hash: "0x1" }, pendingTx: { amount: "1" }, pendingWait: { hash: "0x1" }, }, }, ], }, { 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`, () => { // EVERY value, not some: a PARTIAL floor is still a floor, and // a row that keeps saying LOOSE because one hostile value out // of three still survives is exactly the stale claim this file // exists to stop. for (const value of values) { const out = normalizePersisted( profileWith(row.field, value), ); expect({ value: value, survived: JSON.stringify(out[row.field]), }).toEqual({ value: value, survived: JSON.stringify(value), }); } }); } } }); // --------------------------------------------- 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 }; // unversionedValidProfile() stores no currentView, so every boot in here lands // on Home. That is the cheap half of the proof; the restore path below is the // half that matters. describe("a hostile value for one field, booting onto Home", () => { 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); } }); } }); // ------------------------------------------------ driving the restore path // Everything above lands on Home. Home is not where this class of defect // lives: all three of the false claims this file replaced were falsified by a // RESTORE, through the unguarded restoreView() in src/popup/index.js. So a // swept row's hostile values are driven onto EVERY restorable view, one boot // each. // // This is what makes a LOOSE row falsifiable. A field that gains a structural // dereference on any restorable view — `state.theme.toLowerCase()` in a view's // show(), say — turns the row red here, instead of waiting for a reviewer to // re-derive the claim by hand. // restoreWait() resumes from this, so it has to be a finite number and recent // enough that the resumed deadline has not already passed — a wait that has // outlived its deadline resolves on the first poll instead of staying on // screen. Read once at module load, so every boot in one run shares it. const BROADCAST_TIME = Date.now(); // A viewData well formed for every restorable branch at once, so the only // thing a swept boot can fail on is the field the row corrupts. "the base // profile the sweep corrupts" below proves this really does render each view // rather than falling back — without that, a sweep could pass by never // reaching a renderer at all. const WELL_FORMED_DATA = { hash: "0x1", message: "boom", to: ADDRESS, decoded: { details: [{ address: TOKEN_ADDRESS }] }, tx: { hash: "0x1", from: ADDRESS, to: ADDRESS, contractAddress: null }, pendingTx: { token: "ETH", from: ADDRESS, to: ADDRESS, amount: "1", balance: "2", }, pendingWait: { hash: "0x1", txInfo: { to: ADDRESS, amount: "1" }, broadcastTime: BROADCAST_TIME, }, }; function restoringOnto(view, extra) { return unversionedValidProfile({ currentView: view, selectedWallet: 0, selectedAddress: 0, selectedToken: TOKEN_ADDRESS, viewStack: ["main"], viewData: WELL_FORMED_DATA, ...extra, }); } // A boot that RESTORED is healthy and landed on the view it stored, rather // than falling back to Home — which a healthy boot also does, and which would // let a sweep pass by never running the renderer it is aimed at. async function restoredHealth(profile, view) { const env = await bootPopup(profile); return { errors: env.pageErrors, restored: env.visibleViews().includes(view), }; } const RESTORED = { errors: [], restored: true }; describe("the base profile the sweep corrupts", () => { for (const view of RESTORABLE_VIEWS) { test(`renders ${view} rather than falling back`, async () => { await expect( restoredHealth(restoringOnto(view), view), ).resolves.toEqual(RESTORED); }); } }); // A field the ROUTER itself reads — the two it gates on and the two // hasValidAddress() indexes with. A hostile value in one of these legitimately // changes which view renders, so each gets its own boot per view and is held // only to "healthy", not to "restored onto the view it stored". const routes = (row) => Boolean(row.routes); // Every routing row × every hostile value × every restorable view. Profiles // are deduplicated because a hostile `currentView` REPLACES the view being // restored onto, which would otherwise be the same boot eleven times. describe("a hostile routing value restoring onto", () => { for (const row of CONTRACT) { if (!swept(row) || !routes(row)) continue; const seen = new Set(); for (const value of row.hostile) { for (const view of RESTORABLE_VIEWS) { const profile = restoringOnto(view, { [row.field]: value }); const key = JSON.stringify(profile); if (seen.has(key)) continue; seen.add(key); test(`${view}: ${row.field} = ${JSON.stringify( value, )}`, async () => { await expect(bootHealth(profile)).resolves.toEqual(HEALTHY); }); } } } }); // Every OTHER swept field, corrupted at once, one boot per view per hostile // slot: twelve fields on one boot rather than twelve boots. A field is only in // here because it is not one the router reads — and that is ASSERTED, not // argued, because the boot has to land on `view`. A field that does move the // routing turns this red and has to declare `routes` and take the individual // sweep above. // // Nothing is masked by combining: a throw fails the boot whichever field threw, // and the only other way a dereference could go unseen is the renderer not // running at all, which is exactly what `restored` forbids. When it does go // red, the same view is re-booted one field at a time so the failure names the // fields rather than leaving a reader to bisect twelve of them. const UNROUTED = CONTRACT.filter((row) => swept(row) && !routes(row)); const HOSTILE_SLOTS = Math.max(...UNROUTED.map((row) => row.hostile.length)); function unroutedValues(slot) { const fields = {}; for (const row of UNROUTED) { fields[row.field] = row.hostile[slot % row.hostile.length]; } return fields; } describe("every field the router does not read, corrupted at once, onto", () => { for (const view of RESTORABLE_VIEWS) { for (let slot = 0; slot < HOSTILE_SLOTS; slot++) { test(`${view}: hostile value ${slot + 1} in all ${ UNROUTED.length } of them`, async () => { const fields = unroutedValues(slot); const together = await restoredHealth( restoringOnto(view, fields), view, ); if (together.errors.length === 0 && together.restored) { expect(together).toEqual(RESTORED); return; } const named = []; for (const row of UNROUTED) { const one = await restoredHealth( restoringOnto(view, { [row.field]: fields[row.field], }), view, ); if (one.errors.length === 0 && one.restored) continue; named.push( `${row.field}=${JSON.stringify(fields[row.field])}: ` + (one.errors.join("; ") || `fell off ${view}`), ); } expect({ view: view, fields: named }).toEqual({ view: view, fields: [], }); }); } } }); // The values that only mean something on the restore path: a viewData that // PASSES a branch's gate and then hands its renderer something dereferenced, // and the index values whose route through hasValidAddress() differs from the // row's own hostile set. describe("a restore-only hostile value onto", () => { for (const row of CONTRACT) { for (const entry of row.hostileRestore || []) { for (const view of entry.views || RESTORABLE_VIEWS) { test(`${view}: ${row.field} = ${JSON.stringify( entry.value, )}`, async () => { await expect( bootHealth( restoringOnto(view, { [row.field]: entry.value }), ), ).resolves.toEqual(HEALTHY); }); } } } });