diff --git a/README.md b/README.md index 3cbf3d7..fc4a8fb 100644 --- a/README.md +++ b/README.md @@ -1016,17 +1016,33 @@ generic `-32603` every request used to answer. Every other field of the record is floored in `normalizePersisted()` rather than gated. That floor is a type check for the fields something dereferences -structurally — `trackedTokens`, each address's `tokenBalances`, `networkId`, -`networkEndpoints`, `activeAddress`, `viewStack` — and it checks the ENTRIES as -well as the container, because `[1, 2]` is a list and `t.address` is one level -below an `Array.isArray()`. The remaining fields get a `saved.x || default` or a -present-or-default passthrough that takes the stored value verbatim, with no -type check at all; which field is in which category is listed in the header of +structurally — `trackedTokens`, each address's `tokenBalances`, `allowedSites`, +`deniedSites`, `fraudContracts`, `viewStack`, `networkEndpoints`, plus the +scalars `networkId`, `activeAddress` and `selectedToken` — and it checks the +ENTRIES as well as the container, because `[1, 2]` is a list, +`{"0x…": "notalist"}` is an object, and the dereference is one level below the +container check. A malformed entry is dropped. The remaining fields get a +`saved.x || default` or a present-or-default passthrough that takes the stored +value verbatim, with no type check at all; which field is in which category, and +why the loose floor is still enough for each of them, is listed in the header of `src/shared/stateSchema.js`. A truthy value of the wrong type in a field that IS dereferenced walks through truthiness and throws on the first read, which is the blank popup again by a longer route — so adding a field means choosing between the two by what reads it. +The `allowedSites` case is why the entry check is not optional. A stored +`{"0x…": "notalist"}` is a well-formed object holding a malformed entry: it +passed the gate, rendered a completely healthy popup, and then threw inside +`saveState()`'s per-hostname merge, so every save from that moment on failed and +the user went on operating a wallet that was persisting nothing +([#362](https://git.eeqj.de/sneak/AutistMask/issues/362)). A save that fails is +now also reported rather than swallowed: `onSaveFailure()` in +`src/shared/state.js` is called for every failed save, awaited or not, and the +popup puts up a persistent "NOT SAVED" banner (`showSaveFailureBanner()` in +`src/popup/views/helpers.js`). Storage can still fail for reasons no floor +covers — a quota, a revoked permission, a record a newer build wrote — and the +wallet must never look healthy while that is true. + The `networkId` check is not cosmetic: that value is an object KEY into `state.networkEndpoints`, so an unvalidated `"__proto__"` would set the map's prototype instead of an own key and the user's endpoint would silently not be diff --git a/TODO.md b/TODO.md index f985496..d825f45 100644 --- a/TODO.md +++ b/TODO.md @@ -45,6 +45,31 @@ but the review is broader than any of them. # Completed Steps +- 2026-08-23: A persisted container whose ENTRIES were dereferenced unchecked no + longer reaches a `.map()` or a `.toLowerCase()` + ([#362](https://git.eeqj.de/sneak/AutistMask/issues/362)). `allowedSites` was + the worst shape available: a stored `{"0x…": "notalist"}` passed the gate, + rendered a completely healthy popup, and then threw inside `saveState()`'s + per-hostname merge, so every save from that moment on failed silently and the + user went on operating a wallet that was persisting nothing — measured as + `chrome.storage.local.set` never being called at all. `deniedSites` has the + same shape, `fraudContracts` the same class with a milder consequence (a + broken send screen, since the boot path only reaches it through + `loadHomeTxs()`, which catches), and the sweep for the class turned up + `selectedToken`, which blanked the popup outright when restoring onto + address-token. All four now get the floor + [#311](https://git.eeqj.de/sneak/AutistMask/issues/311) settled — container + AND entries, malformed entries dropped — through `textList()` and `siteMap()` + beside the existing `tokenRefs()` in `src/shared/persistedState.js`. Site-map + keys are written with `defineProperty` for the same reason `networkEndpoints`' + are. Separately, a save that fails is no longer swallowed: `onSaveFailure()` + in `src/shared/state.js` reports every failed save, awaited or not, and the + popup raises a persistent "NOT SAVED" banner; the background refresh loop no + longer turns a failure into an unhandled rejection instead of a report. The + field-by-field categorisation in the header of `src/shared/stateSchema.js`, + and its mirror in `README.md`, were re-verified against the code and moved + with the change. + - 2026-08-23: A swap whose output token the calldata never named is said to be unknown instead of being called ETH ([#353](https://git.eeqj.de/sneak/AutistMask/issues/353)). `tokenInfo(null)` diff --git a/src/popup/index.js b/src/popup/index.js index 1a7e01e..a1c2112 100644 --- a/src/popup/index.js +++ b/src/popup/index.js @@ -1,9 +1,14 @@ // AutistMask popup entry point. // Loads state, initializes views, triggers first render. -const { state, saveState, loadState } = require("../shared/state"); +const { + state, + saveState, + onSaveFailure, + loadState, +} = require("../shared/state"); const { StateUnusableError } = require("../shared/stateSchema"); -const { setRuntimeDebug } = require("../shared/log"); +const { log, setRuntimeDebug } = require("../shared/log"); const { refreshPrices } = require("../shared/prices"); const { refreshBalances } = require("../shared/balances"); const { @@ -11,6 +16,7 @@ const { showView, updateDebugBanner, setBackRenderer, + showSaveFailureBanner, pushCurrentView, goBack, } = require("./views/helpers"); @@ -61,6 +67,14 @@ async function doRefreshAndRender() { state.lastBalanceRefresh = Date.now(); await saveState(); renderWalletList(); + } catch (e) { + // Every call site fires this and walks away — the boot below, the ten + // second interval, and eight views through ctx — so it must never + // reject: an unhandled rejection is not a report of anything. The save + // inside it reports its own failure through onSaveFailure() (see + // src/shared/state.js); what is left here is a failed network round + // trip, which the next tick retries. + log.errorf("popup: background refresh failed:", e); } finally { refreshInFlight = false; } @@ -136,6 +150,12 @@ function fallbackView() { } async function init() { + // First, before anything can save: showView() saves on every navigation + // without awaiting, so a save that fails from here on has somewhere to be + // reported rather than being swallowed by the save queue + // (https://git.eeqj.de/sneak/AutistMask/issues/362). Registered ahead of + // the approval-window branch below too, since that window saves as well. + onSaveFailure(showSaveFailureBanner); try { await loadState(); } catch (e) { diff --git a/src/popup/views/helpers.js b/src/popup/views/helpers.js index 87bc357..133c47b 100644 --- a/src/popup/views/helpers.js +++ b/src/popup/views/helpers.js @@ -132,6 +132,38 @@ function updateDebugBanner(viewName) { } } +// The banner shown when a save has failed, registered as the save-failure +// reporter by src/popup/index.js. +// +// Persistent and not dismissable, unlike showFlash(): what it says is true +// until the popup is closed, and a message that clears itself after two seconds +// is how the user goes on operating a wallet that is persisting nothing +// (https://git.eeqj.de/sneak/AutistMask/issues/362). It survives navigation +// because it hangs off document.body rather than off a view. +// +// Created on demand rather than authored in index.html, the same way +// updateDebugBanner() creates its own: it is absent from a popup where nothing +// has failed, which is the state that must not need markup to be in. +// +// textContent, never innerHTML: `detail` carries an error message, which may +// come from the browser's storage layer. +function showSaveFailureBanner(detail) { + let banner = document.getElementById("save-failure-banner"); + if (!banner) { + banner = document.createElement("div"); + banner.id = "save-failure-banner"; + banner.style.cssText = + "background:#c00;color:#fff;text-align:center;font-size:10px;padding:2px 4px;font-family:monospace;position:sticky;top:0;z-index:10000;"; + document.body.prepend(banner); + } + const message = (detail && (detail.message || detail.problem)) || detail; + banner.textContent = + "NOT SAVED — AutistMask could not write to storage, so recent" + + " changes are not stored. Close and reopen the popup; if this keeps" + + " happening, do not rely on anything you change now." + + (message ? " (" + String(message) + ")" : ""); +} + // Callback that renders a view being navigated BACK onto. Set once by // index.js via setBackRenderer(), which routes the view through the same // per-view render and data guards restoreView() uses. @@ -516,6 +548,7 @@ module.exports = { showView, onViewLeave, updateDebugBanner, + showSaveFailureBanner, setBackRenderer, pushCurrentView, goBack, diff --git a/src/shared/persistedState.js b/src/shared/persistedState.js index 414cbb1..42a576b 100644 --- a/src/shared/persistedState.js +++ b/src/shared/persistedState.js @@ -92,6 +92,57 @@ function tokenRefs(value) { ); } +// 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. Keys are written +// with defineProperty for the same reason networkEndpoints' are — a stored own +// "__proto__" key, which JSON can carry, would otherwise be handed to the +// prototype setter and recorded nowhere. +function siteMap(value) { + const out = {}; + if (!isRecord(value)) return out; + for (const address of Object.keys(value)) { + const hostnames = textList(value[address]); + if (hostnames.length === 0) continue; + Object.defineProperty(out, address, { + value: hostnames, + writable: true, + enumerable: true, + configurable: true, + }); + } + return out; +} + // 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 @@ -239,14 +290,8 @@ function normalizePersisted(saved) { 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.allowedSites = siteMap(saved.allowedSites); + out.deniedSites = siteMap(saved.deniedSites); out.rememberSiteChoice = saved.rememberSiteChoice !== undefined ? saved.rememberSiteChoice @@ -279,7 +324,12 @@ function normalizePersisted(saved) { : 100000; out.utcTimestamps = saved.utcTimestamps !== undefined ? saved.utcTimestamps : false; - out.fraudContracts = structuredClone(saved.fraudContracts || []); + // 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; @@ -288,7 +338,20 @@ function normalizePersisted(saved) { saved.selectedWallet !== undefined ? saved.selectedWallet : null; out.selectedAddress = saved.selectedAddress !== undefined ? saved.selectedAddress : null; - out.selectedToken = saved.selectedToken || null; + // "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; diff --git a/src/shared/state.js b/src/shared/state.js index 08442c6..f4cbc75 100644 --- a/src/shared/state.js +++ b/src/shared/state.js @@ -522,11 +522,51 @@ async function saveStateOnce() { // begins, so each one only ever sees the true live state at its turn. let saveQueue = Promise.resolve(); +// Where a failed save is REPORTED, set once by the context that has a screen +// to say it on (src/popup/index.js). +// +// A save that fails must not fail silently. showView() fires saveState() on +// every navigation without awaiting it, and the queue below has to attach a +// rejection handler to keep advancing — so a failing save was swallowed +// entirely: no throw, no message, nothing on screen. The wallet kept running +// against storage that was rejecting every write, which is the data-loss half +// of https://git.eeqj.de/sneak/AutistMask/issues/362. The awaited callers were +// no better off: `await saveState()` inside an unguarded event handler surfaces +// in the console and nowhere the user looks. +// +// This is the "tell the user" half; the other half is the floor in +// normalizePersisted(), which stops the malformed-record cause from arising in +// the first place. Both, because a floor only covers the causes it knows about +// and storage can still fail for reasons of its own (quota, a revoked +// permission, a record a newer build wrote). +let saveFailureHandler = null; + +function onSaveFailure(fn) { + saveFailureHandler = fn; +} + +function reportSaveFailure(err) { + log.errorf("state: saving failed, changes were NOT persisted:", err); + if (!saveFailureHandler) return; + try { + saveFailureHandler(err); + } catch (e) { + // The reporter is the last thing standing between a failed save and + // silence; a reporter that throws must not become an unhandled + // rejection of its own on top of it. + log.errorf("state: the save-failure reporter itself failed:", e); + } +} + function saveState() { const turn = saveQueue.then(saveStateOnce); // The queue must advance even when a save rejects, or every save after // it queues behind a promise that never settles. saveQueue = turn.catch(() => {}); + // Every failed save is reported, whether or not the caller awaited this + // one. The returned promise still rejects, so a caller that DOES await + // keeps its own error handling. + turn.catch(reportSaveFailure); return turn; } @@ -574,6 +614,7 @@ function currentAddress() { module.exports = { state, saveState, + onSaveFailure, loadState, currentAddress, currentNetwork, diff --git a/src/shared/stateSchema.js b/src/shared/stateSchema.js index fb12cbc..6df1d19 100644 --- a/src/shared/stateSchema.js +++ b/src/shared/stateSchema.js @@ -31,18 +31,33 @@ // which one a given field has without reading it off: // // Type-checked, container AND entries: trackedTokens, each address's -// tokenBalances, networkId, networkEndpoints, activeAddress, viewStack. -// These are the fields something dereferences structurally — iterated, -// indexed, assigned into, or .toLowerCase()'d — where a truthy value of -// the wrong type throws on the first read. The entries matter as much as -// the container: [1, 2] IS a list, and `t.address` is one level below the -// Array.isArray(). -// Container shape only: allowedSites, deniedSites. A falsy value or a list -// becomes {}; anything else is taken as stored and the entries are not -// checked. +// tokenBalances, allowedSites, deniedSites, fraudContracts, viewStack, +// networkEndpoints. These are the fields something dereferences +// structurally — iterated, indexed, assigned into, or .toLowerCase()'d — +// where a truthy value of the wrong type throws on the first read. The +// entries matter as much as the container: [1, 2] IS a list, {"0x…": +// "notalist"} IS an object, and the dereference is one level below the +// container check. A malformed entry is dropped. networkEndpoints is the +// one whose entries are coerced rather than dropped: each value is spread +// into a fresh record, so a stored scalar becomes a record with no +// rpcUrl/blockscoutUrl and falls to the network defaults. +// Type-checked scalar: networkId, activeAddress, selectedToken. Each is +// dereferenced as text or used as an object key, so a truthy value of the +// wrong type throws or lands somewhere it should not; each falls back to +// its default or to null. // `saved.x || default`, no type check: rpcUrl, blockscoutUrl, -// lastBalanceRefresh, fraudContracts, tokenHolderCache, theme, -// currentView, selectedToken, viewData. +// lastBalanceRefresh, tokenHolderCache, theme, currentView, viewData. +// None of these is dereferenced structurally, which is why the loose +// floor is still enough, and each reason is a fact about the readers, not +// a promise: the two URLs are only concatenated into a fetch URL and +// handed to ethers, where a bad value fails the request on a path that +// already catches; lastBalanceRefresh is only arithmetic; theme and +// currentView are only compared and concatenated (both fall through to a +// default branch, and currentView is additionally gated by +// RESTORABLE_VIEWS.has() before anything renders from it); viewData is +// only read field-by-field, and a field of a string or a number is +// undefined rather than a throw; nothing in src/ reads tokenHolderCache at +// all — it is only ever reset wholesale. // Present-or-default, value taken verbatim: every boolean flag, // dustThresholdGwei, selectedWallet, selectedAddress. // diff --git a/tests/persistedEntryFloors.test.js b/tests/persistedEntryFloors.test.js new file mode 100644 index 0000000..0ac72eb --- /dev/null +++ b/tests/persistedEntryFloors.test.js @@ -0,0 +1,377 @@ +// A persisted container that is checked while its ENTRIES are dereferenced +// unchecked (https://git.eeqj.de/sneak/AutistMask/issues/362). +// +// https://git.eeqj.de/sneak/AutistMask/issues/311 settled the idiom — floor the +// container AND its entries, dropping anything that cannot be safely +// dereferenced — and applied it to trackedTokens and tokenBalances. These are +// the fields it did not reach. +// +// allowedSites is the worst shape in the codebase, and it is what the boot +// tests below measure: a stored `{"0x…": "notalist"}` passes the gate, renders +// a WORKING popup, and then throws `base.map is not a function` inside +// saveState()'s merge — so every save from then on fails while the UI looks +// entirely healthy and the user goes on operating a wallet that is persisting +// nothing. A blank popup is at least visibly broken; this is not. So the +// assertion here is never merely "the popup rendered": it is "the popup +// rendered AND the write actually landed in storage". +// +// Observed at ad6aa7b, with the floors below removed: +// allowedSites: {"0x…": "notalist"} -> views=["main"], errors=[], and +// storage.set NEVER called: the stored record kept no schemaVersion, so +// nothing the user did was persisted. +// fraudContracts: "0x…" -> renderSendTokenSelect() threw +// "(state.fraudContracts || []).map is not a function" +// fraudContracts: [42] -> threw "a.toLowerCase is not a +// function" +// selectedToken: 42 (restoring onto address-token) -> views=[], errors= +// ["tokenId.toLowerCase is not a function"] — a blank popup. + +const { normalizePersisted } = require("../src/shared/persistedState"); +const { makeStorageStub } = require("./support/storageStub"); +const { + bootPopup, + cleanupPopup, + unversionedValidProfile, + ADDRESS, + TOKEN_ADDRESS, +} = require("./support/popupBoot"); + +// One extension page: a fresh module registry over the given storage. state.js +// resolves the storage API at require time, so the stub has to be installed +// before the module is loaded. +function loadStateModule(storage) { + jest.resetModules(); + globalThis.chrome = { storage: { local: storage.local } }; + return require("../src/shared/state"); +} + +afterEach(() => { + cleanupPopup(); +}); + +// ------------------------------------------------------- the floor itself + +describe("the floor under allowedSites and deniedSites", () => { + for (const field of ["allowedSites", "deniedSites"]) { + test(`${field} that is not a record becomes an empty record`, () => { + for (const bad of ["nope", 42, true, [ADDRESS], null]) { + expect(normalizePersisted({ [field]: bad })[field]).toEqual({}); + } + }); + + test(`an ${field} entry whose value is not a hostname list is dropped`, () => { + for (const bad of ["dapp.example", 42, null, { a: 1 }, true]) { + expect( + normalizePersisted({ [field]: { [ADDRESS]: bad } })[field], + ).toEqual({}); + } + }); + + test(`a hostname that is not text is dropped from an ${field} entry`, () => { + expect( + normalizePersisted({ + [field]: { [ADDRESS]: [42, null, "dapp.example", {}] }, + })[field], + ).toEqual({ [ADDRESS]: ["dapp.example"] }); + }); + + test(`a real ${field} map survives, copied not shared`, () => { + const saved = { [field]: { [ADDRESS]: ["dapp.example"] } }; + + const out = normalizePersisted(saved); + + expect(out[field]).toEqual(saved[field]); + expect(out[field]).not.toBe(saved[field]); + expect(out[field][ADDRESS]).not.toBe(saved[field][ADDRESS]); + }); + + test(`a good ${field} entry beside a malformed one survives`, () => { + const out = normalizePersisted({ + [field]: { [ADDRESS]: ["dapp.example"], [TOKEN_ADDRESS]: 42 }, + }); + + expect(out[field]).toEqual({ [ADDRESS]: ["dapp.example"] }); + }); + + test(`a stored own "__proto__" key in ${field} does not become a prototype`, () => { + // JSON can carry the key, and plain assignment would hand it to + // the prototype setter — recording no entry and, worse, moving the + // map's prototype. Same reason networkEndpoints uses + // defineProperty. + const saved = JSON.parse( + '{"' + field + '":{"__proto__":["evil.invalid"]}}', + ); + + const out = normalizePersisted(saved); + + expect(Object.getPrototypeOf(out[field])).toBe(Object.prototype); + expect(Object.keys(out[field])).toEqual(["__proto__"]); + expect({}.length).toBeUndefined(); + }); + } +}); + +describe("the floor under fraudContracts", () => { + test("fraudContracts that is not a list becomes an empty list", () => { + for (const bad of ["nope", 42, true, { a: 1 }]) { + expect( + normalizePersisted({ fraudContracts: bad }).fraudContracts, + ).toEqual([]); + } + }); + + test("a fraudContracts entry that is not text is dropped", () => { + expect( + normalizePersisted({ + fraudContracts: [42, null, TOKEN_ADDRESS, {}, []], + }).fraudContracts, + ).toEqual([TOKEN_ADDRESS]); + }); + + test("a real fraudContracts list survives, copied not shared", () => { + const saved = { fraudContracts: [TOKEN_ADDRESS] }; + + const out = normalizePersisted(saved); + + expect(out.fraudContracts).toEqual(saved.fraudContracts); + expect(out.fraudContracts).not.toBe(saved.fraudContracts); + }); +}); + +describe("the floor under selectedToken", () => { + // Found by the sweep for this defect class, not named in the issue: the + // restore gate in src/popup/viewRouter.js checks truthiness only, and both + // src/popup/views/addressToken.js and src/popup/views/receive.js then + // dereference it as text. + test("a selectedToken that is not text becomes null", () => { + for (const bad of [42, true, { a: 1 }, [TOKEN_ADDRESS]]) { + expect( + normalizePersisted({ selectedToken: bad }).selectedToken, + ).toBeNull(); + } + }); + + test("a real selectedToken survives; the empty string becomes null", () => { + expect( + normalizePersisted({ selectedToken: TOKEN_ADDRESS }).selectedToken, + ).toBe(TOKEN_ADDRESS); + expect(normalizePersisted({ selectedToken: "ETH" }).selectedToken).toBe( + "ETH", + ); + expect( + normalizePersisted({ selectedToken: "" }).selectedToken, + ).toBeNull(); + }); +}); + +// ----------------------------------------- what the user actually gets + +describe("a malformed allowedSites entry", () => { + const MALFORMED = [ + { name: "a string", value: "notalist" }, + { name: "a number", value: 42 }, + { name: "a record", value: { hostnames: ["dapp.example"] } }, + ]; + + for (const { name, value } of MALFORMED) { + test(`whose value is ${name}: a working popup whose writes persist`, async () => { + const env = await bootPopup( + unversionedValidProfile({ + allowedSites: { [ADDRESS]: value }, + }), + ); + + expect({ + visibleViews: env.visibleViews(), + errors: env.pageErrors, + }).toEqual({ visibleViews: ["main"], errors: [] }); + + // The half that matters. A popup that renders and never persists + // again is worse than one that renders nothing, because nothing + // tells the user. The version stamp is proof a write landed: it + // is absent from the stored record until saveState() writes one. + expect(env.storage.set).toHaveBeenCalled(); + const stored = env.storage.read("autistmask"); + expect(stored.schemaVersion).toBe(1); + expect(stored.wallets[0].encryptedSecret).toBe( + "encrypted-secret-1", + ); + expect(stored.allowedSites).toEqual({}); + }); + } + + test("the well-formed entries beside it keep working", async () => { + const env = await bootPopup( + unversionedValidProfile({ + allowedSites: { + [ADDRESS]: ["dapp.example"], + [TOKEN_ADDRESS]: "notalist", + }, + }), + ); + + expect(env.pageErrors).toEqual([]); + expect(env.storage.read("autistmask").allowedSites).toEqual({ + [ADDRESS]: ["dapp.example"], + }); + }); + + test("a later save still lands, not just the first", async () => { + // The failure this closes was in the MERGE, which runs on every save + // against whatever is in storage at the time. One write landing is not + // enough: the field has to stay mergeable. + const storage = makeStorageStub({ + autistmask: unversionedValidProfile({ + allowedSites: { [ADDRESS]: "notalist" }, + }), + }); + const { state, loadState, saveState } = loadStateModule(storage); + + await loadState(); + state.theme = "dark"; + await saveState(); + state.utcTimestamps = true; + await saveState(); + + const stored = storage.read("autistmask"); + expect(stored.theme).toBe("dark"); + expect(stored.utcTimestamps).toBe(true); + expect(stored.allowedSites).toEqual({}); + expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1"); + }); +}); + +describe("a malformed fraudContracts", () => { + // The send screen, which is where this one lands: the boot path only + // reaches fraudContracts through loadHomeTxs(), which catches, so the + // consequence is an unusable send screen rather than silent data loss. + function stubSendDocument() { + const select = { innerHTML: "", children: [] }; + select.appendChild = (child) => select.children.push(child); + globalThis.document = { + getElementById: (id) => (id === "send-token" ? select : null), + createElement: () => ({ value: "", textContent: "" }), + }; + return select; + } + + const HELD = { + address: TOKEN_ADDRESS, + symbol: "AAA", + decimals: 18, + balance: "12.5", + holders: 50000, + }; + + async function sendScreenTokens(fraudContracts) { + const storage = makeStorageStub({ + autistmask: unversionedValidProfile({ fraudContracts }), + }); + const { loadState } = loadStateModule(storage); + await loadState(); + const select = stubSendDocument(); + const { renderSendTokenSelect } = require("../src/popup/views/send"); + + renderSendTokenSelect({ address: ADDRESS, tokenBalances: [HELD] }); + + return select.children.map((opt) => opt.value); + } + + for (const bad of ["notalist", 42, { a: 1 }, [42], [null], [{}]]) { + test(`${JSON.stringify(bad)}: a usable send screen`, async () => { + await expect(sendScreenTokens(bad)).resolves.toEqual([ + TOKEN_ADDRESS, + ]); + }); + } + + test("a real fraud entry beside a malformed one still hides its token", async () => { + await expect( + sendScreenTokens([42, TOKEN_ADDRESS.toLowerCase()]), + ).resolves.toEqual([]); + }); +}); + +describe("a malformed selectedToken", () => { + test("does not blank the popup on restore", async () => { + const env = await bootPopup( + unversionedValidProfile({ + currentView: "address-token", + selectedWallet: 0, + selectedAddress: 0, + selectedToken: 42, + viewStack: ["main", "address"], + }), + ); + + expect({ + visibleViews: env.visibleViews(), + errors: env.pageErrors, + }).toEqual({ visibleViews: ["main"], errors: [] }); + }); +}); + +// --------------------------------------------- a save that fails is told + +describe("a save that fails", () => { + function failingStorage(profile) { + const storage = makeStorageStub({ autistmask: profile }); + const realSet = storage.local.set; + storage.local.set = jest.fn(async () => { + throw new Error("QUOTA_BYTES quota exceeded"); + }); + storage.restoreWrites = () => { + storage.local.set = realSet; + }; + return storage; + } + + test("is reported, not swallowed by the save queue", async () => { + const storage = failingStorage(unversionedValidProfile()); + const { state, loadState, saveState, onSaveFailure } = + loadStateModule(storage); + const failures = []; + onSaveFailure((e) => failures.push(String(e && e.message))); + + await loadState(); + state.theme = "dark"; + // Not awaited, which is how showView() saves on every navigation and + // how the failure used to disappear entirely. + saveState(); + for (let i = 0; i < 50; i++) await Promise.resolve(); + + expect(failures).toEqual(["QUOTA_BYTES quota exceeded"]); + }); + + test("still rejects for a caller that awaits it", async () => { + const storage = failingStorage(unversionedValidProfile()); + const { state, loadState, saveState, onSaveFailure } = + loadStateModule(storage); + onSaveFailure(() => {}); + + await loadState(); + state.theme = "dark"; + + await expect(saveState()).rejects.toThrow("QUOTA_BYTES"); + }); + + test("puts a banner on the popup saying nothing is being saved", async () => { + const env = await bootPopup(undefined, { + storage: failingStorage(unversionedValidProfile()), + }); + + // The popup is still usable — the point is that it no longer looks + // healthy while silently persisting nothing. + expect(env.visibleViews()).toEqual(["main"]); + const banner = env.node("save-failure-banner"); + expect(banner).not.toBeNull(); + expect(banner.textContent).toContain("NOT SAVED"); + expect(banner.textContent).toContain("QUOTA_BYTES quota exceeded"); + }); + + test("no banner appears on a popup whose saves work", async () => { + const env = await bootPopup(unversionedValidProfile()); + + expect(env.node("save-failure-banner")).toBeNull(); + }); +}); diff --git a/tests/popupElementIds.test.js b/tests/popupElementIds.test.js index c4a188f..2f93efc 100644 --- a/tests/popupElementIds.test.js +++ b/tests/popupElementIds.test.js @@ -35,6 +35,11 @@ const POPUP_HTML_PATH = path.join(POPUP_DIR, "index.html"); const RUNTIME_CREATED_IDS = new Set([ // Created by updateDebugBanner() in src/popup/views/helpers.js. "debug-banner", + // Created by showSaveFailureBanner() in the same file, on the first save + // that fails. Absent from the markup on purpose: a popup where nothing has + // failed must not have to carry an empty banner + // (https://git.eeqj.de/sneak/AutistMask/issues/362). + "save-failure-banner", ]); // Every id lookup the popup performs with a literal argument, as diff --git a/tests/stateRecovery.test.js b/tests/stateRecovery.test.js index a7e48b9..c76c7b7 100644 --- a/tests/stateRecovery.test.js +++ b/tests/stateRecovery.test.js @@ -13,61 +13,26 @@ // calls, is exactly the defect: what has to be true is that BOOTING the popup // on a bad blob lands on it. // -// The DOM stub is built FROM src/popup/index.html — every id in the markup, -// with the classes the markup gives it — so "which views are visible" is -// answered against the real element set, and a recovery screen with no markup -// behind it cannot pass. +// The boot harness and its DOM stub — built FROM src/popup/index.html, so +// "which views are visible" is answered against the real element set — live in +// tests/support/popupBoot.js, since tests/persistedEntryFloors.test.js needs +// the same boot. // // The fourth case is the upgrade one, and it is the case that must NOT reach // the recovery screen: every install in the field has a valid profile with no // version field, and showing those users a wipe prompt would be a worse defect // than the one being fixed. It is migrated in place and keeps working. -const fs = require("fs"); -const path = require("path"); - -const { makeStorageStub } = require("./support/storageStub"); - -const POPUP_HTML = fs.readFileSync( - path.join(__dirname, "..", "src", "popup", "index.html"), - "utf8", -); - -// Fixed address, never used for anything but these tests. -const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; -// A fixed ERC-20 contract address, same rule. -const TOKEN_ADDRESS = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; +const { + bootPopup, + cleanupPopup, + unversionedValidProfile, + ADDRESS, + TOKEN_ADDRESS, +} = require("./support/popupBoot"); // ------------------------------------------------------------- fixtures -// A profile in the shape every install in the field has it: complete, valid, -// and carrying no version field, because no build ever wrote one. -function unversionedValidProfile() { - return { - hasWallet: true, - wallets: [ - { - type: "hd", - name: "Wallet 1", - xpub: "xpub-wallet-1", - encryptedSecret: "encrypted-secret-1", - nextIndex: 1, - addresses: [ - { address: ADDRESS, balance: "1.5", tokenBalances: [] }, - ], - }, - ], - activeAddress: ADDRESS, - networkId: "mainnet", - rpcUrl: "https://ethereum-rpc.publicnode.com", - blockscoutUrl: "https://eth.blockscout.com/api/v2", - allowedSites: { [ADDRESS]: ["dapp.example"] }, - deniedSites: {}, - trackedTokens: [], - theme: "system", - }; -} - // The three blobs from the issue, each with the error it produced. const CORRUPT_BLOBS = [ { @@ -103,235 +68,7 @@ const CORRUPT_BLOBS = [ }, ]; -// ------------------------------------------------------------- DOM stub - -function makeElement(id, className) { - const classes = new Set( - (className || "").split(/\s+/).filter((name) => name !== ""), - ); - const el = { - id, - tagName: "DIV", - textContent: "", - value: "", - innerHTML: "", - href: "", - download: "", - disabled: false, - style: {}, - dataset: {}, - listeners: {}, - clicked: 0, - classList: { - add: (...names) => names.forEach((n) => classes.add(n)), - remove: (...names) => names.forEach((n) => classes.delete(n)), - contains: (n) => classes.has(n), - toggle: (n, force) => { - const on = force === undefined ? !classes.has(n) : force; - if (on) classes.add(n); - else classes.delete(n); - return on; - }, - }, - addEventListener: (name, fn) => { - el.listeners[name] = el.listeners[name] || []; - el.listeners[name].push(fn); - }, - removeEventListener: () => {}, - appendChild: () => {}, - remove: () => {}, - focus: () => {}, - select: () => {}, - setAttribute: (name, value) => { - el[name] = value; - }, - querySelector: () => null, - querySelectorAll: () => [], - click: () => { - el.clicked += 1; - }, - }; - return el; -} - -// Every id in the markup, with the classes the markup gives it. A view the -// popup is supposed to reveal has to exist here, which means it has to exist -// in src/popup/index.html. -function idsFromHtml(html) { - const out = new Map(); - const tags = html.match(/<[a-zA-Z][^>]*>/g) || []; - for (const tag of tags) { - const id = /\bid="([^"]+)"/.exec(tag); - if (!id) continue; - const cls = /\bclass="([^"]*)"/.exec(tag); - out.set(id[1], cls ? cls[1] : ""); - } - return out; -} - -function makeDocument(html) { - const authored = idsFromHtml(html); - const els = new Map(); - for (const [id, className] of authored) { - els.set(id, makeElement(id, className)); - } - const created = []; - const doc = { - listeners: {}, - getElementById(id) { - // Created on demand by updateDebugBanner(); absent is the state a - // non-debug, non-testnet popup is in. - if (id === "debug-banner") return null; - if (!els.has(id)) els.set(id, makeElement(id, "")); - return els.get(id); - }, - createElement(tag) { - const el = makeElement("created-" + tag, ""); - el.tagName = String(tag).toUpperCase(); - created.push(el); - return el; - }, - addEventListener(name, fn) { - doc.listeners[name] = doc.listeners[name] || []; - doc.listeners[name].push(fn); - }, - querySelectorAll: () => [], - documentElement: makeElement("html", ""), - body: { - prepend: () => {}, - appendChild: () => {}, - removeChild: () => {}, - }, - elements: els, - authoredIds: authored, - created, - }; - return doc; -} - -// ------------------------------------------------------------- harness - -// Boot the real popup entry point over `stored`, exactly as the browser does: -// storage already holds the record, the page loads, DOMContentLoaded fires. -async function bootPopup(stored) { - jest.resetModules(); - - // The two modules that reach the network. Neither is on the path under - // test; both would make this suite hit the internet. - jest.doMock("../src/shared/prices", () => ({ - prices: {}, - refreshPrices: jest.fn(async () => {}), - clearPrices: jest.fn(), - getPrice: () => null, - formatUsd: () => "", - formatAddressTotal: () => "", - getAddressValue: () => ({ usd: null, partial: false }), - getWalletValue: () => ({ usd: null, partial: false }), - getTotalValue: () => ({ usd: null, partial: false }), - })); - jest.doMock("../src/shared/balances", () => ({ - fetchTokenBalances: jest.fn(async () => []), - refreshBalances: jest.fn(async () => {}), - lookupTokenInfo: jest.fn(async () => null), - getProvider: () => ({}), - scanForAddresses: jest.fn(async () => []), - })); - jest.doMock("../src/shared/transactions", () => ({ - fetchRecentTransactions: jest.fn(async () => []), - filterTransactions: () => [], - })); - - const storage = makeStorageStub( - stored === undefined ? {} : { autistmask: stored }, - ); - const document = makeDocument(POPUP_HTML); - const reloads = []; - - globalThis.chrome = { - storage: { local: storage.local }, - runtime: { - sendMessage: jest.fn(async () => ({})), - getURL: (p) => "chrome-extension://autistmask/" + p, - onMessage: { addListener: () => {} }, - }, - }; - globalThis.document = document; - globalThis.window = { - location: { - search: "", - href: "chrome-extension://autistmask/src/popup/index.html", - reload: () => reloads.push(Date.now()), - }, - matchMedia: () => ({ - matches: false, - addEventListener: () => {}, - removeEventListener: () => {}, - }), - addEventListener: () => {}, - }; - // The 10s refresh loop init() starts would outlive the test. - const realSetInterval = globalThis.setInterval; - globalThis.setInterval = () => 0; - - require("../src/popup/index"); - - const booted = []; - for (const fn of document.listeners.DOMContentLoaded || []) { - booted.push(fn()); - } - - // What the browser console would have shown. A throw out of init() is the - // blank popup this issue is about, so it is captured rather than thrown: - // the assertion that matters is what ended up on screen. - const pageErrors = []; - for (const p of booted) { - try { - await p; - } catch (e) { - pageErrors.push(String((e && e.message) || e)); - } - } - await settle(); - - globalThis.setInterval = realSetInterval; - - return { - storage, - document, - pageErrors, - reloaded: () => reloads.length, - node: (id) => document.getElementById(id), - text: (id) => document.getElementById(id).textContent, - value: (id) => document.getElementById(id).value, - hidden: (id) => - document.getElementById(id).classList.contains("hidden"), - click: async (id) => { - const el = document.getElementById(id); - const fns = el.listeners.click || []; - for (const fn of fns) await fn(); - await settle(); - }, - // The view ids whose section is not hidden, as the audit measured them. - visibleViews: () => { - const out = []; - for (const [id, el] of document.elements) { - if (!id.startsWith("view-")) continue; - if (!el.classList.contains("hidden")) out.push(id.slice(5)); - } - return out; - }, - }; -} - -async function settle() { - for (let i = 0; i < 50; i++) await Promise.resolve(); -} - -afterEach(() => { - delete globalThis.chrome; - delete globalThis.document; - delete globalThis.window; -}); +afterEach(cleanupPopup); // --------------------------------------------------------------- tests diff --git a/tests/support/popupBoot.js b/tests/support/popupBoot.js new file mode 100644 index 0000000..afdb9b5 --- /dev/null +++ b/tests/support/popupBoot.js @@ -0,0 +1,318 @@ +// Boot the REAL popup entry point over a stored record, exactly as the browser +// does: storage already holds the record, the page loads, DOMContentLoaded +// fires. +// +// Written for https://git.eeqj.de/sneak/AutistMask/issues/311 inside +// tests/stateRecovery.test.js and lifted here unchanged in substance when +// https://git.eeqj.de/sneak/AutistMask/issues/362 needed the same boot for a +// second field. Assertions about a corrupt stored profile have to be made +// through the entry point rather than against a view module: a screen that +// renders perfectly when something calls it, and that nothing calls, IS the +// defect. +// +// The DOM stub is built FROM src/popup/index.html — every id in the markup, +// with the classes the markup gives it — so "which views are visible" is +// answered against the real element set, and a screen with no markup behind it +// cannot pass. + +const fs = require("fs"); +const path = require("path"); + +const { makeStorageStub } = require("./storageStub"); + +const POPUP_HTML = fs.readFileSync( + path.join(__dirname, "..", "..", "src", "popup", "index.html"), + "utf8", +); + +// Fixed addresses, never used for anything but these tests. +const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; +const TOKEN_ADDRESS = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + +// A profile in the shape every install in the field has it: complete, valid, +// and carrying no version field, because no build ever wrote one. The starting +// point for "and now corrupt exactly one field of it". +function unversionedValidProfile(extra) { + return { + hasWallet: true, + wallets: [ + { + type: "hd", + name: "Wallet 1", + xpub: "xpub-wallet-1", + encryptedSecret: "encrypted-secret-1", + nextIndex: 1, + addresses: [ + { address: ADDRESS, balance: "1.5", tokenBalances: [] }, + ], + }, + ], + activeAddress: ADDRESS, + networkId: "mainnet", + rpcUrl: "https://ethereum-rpc.publicnode.com", + blockscoutUrl: "https://eth.blockscout.com/api/v2", + allowedSites: { [ADDRESS]: ["dapp.example"] }, + deniedSites: {}, + trackedTokens: [], + theme: "system", + ...(extra || {}), + }; +} + +function makeElement(id, className) { + const classes = new Set( + (className || "").split(/\s+/).filter((name) => name !== ""), + ); + const el = { + id, + tagName: "DIV", + textContent: "", + value: "", + innerHTML: "", + href: "", + download: "", + disabled: false, + style: {}, + dataset: {}, + listeners: {}, + clicked: 0, + classList: { + add: (...names) => names.forEach((n) => classes.add(n)), + remove: (...names) => names.forEach((n) => classes.delete(n)), + contains: (n) => classes.has(n), + toggle: (n, force) => { + const on = force === undefined ? !classes.has(n) : force; + if (on) classes.add(n); + else classes.delete(n); + return on; + }, + }, + addEventListener: (name, fn) => { + el.listeners[name] = el.listeners[name] || []; + el.listeners[name].push(fn); + }, + removeEventListener: () => {}, + appendChild: () => {}, + remove: () => {}, + focus: () => {}, + select: () => {}, + setAttribute: (name, value) => { + el[name] = value; + }, + querySelector: () => null, + querySelectorAll: () => [], + click: () => { + el.clicked += 1; + }, + }; + return el; +} + +// Every id in the markup, with the classes the markup gives it. A view the +// popup is supposed to reveal has to exist here, which means it has to exist +// in src/popup/index.html. +function idsFromHtml(html) { + const out = new Map(); + const tags = html.match(/<[a-zA-Z][^>]*>/g) || []; + for (const tag of tags) { + const id = /\bid="([^"]+)"/.exec(tag); + if (!id) continue; + const cls = /\bclass="([^"]*)"/.exec(tag); + out.set(id[1], cls ? cls[1] : ""); + } + return out; +} + +// Ids the popup creates at runtime rather than authoring in the markup, and +// that must therefore read as ABSENT until something creates them. Answering +// with a fresh element instead would make "is the banner up?" always true. +const RUNTIME_IDS = new Set(["debug-banner", "save-failure-banner"]); + +function makeDocument(html) { + const authored = idsFromHtml(html); + const els = new Map(); + for (const [id, className] of authored) { + els.set(id, makeElement(id, className)); + } + const created = []; + const prepended = []; + const doc = { + listeners: {}, + getElementById(id) { + if (RUNTIME_IDS.has(id) && !els.has(id)) return null; + if (!els.has(id)) els.set(id, makeElement(id, "")); + return els.get(id); + }, + createElement(tag) { + const el = makeElement("created-" + tag, ""); + el.tagName = String(tag).toUpperCase(); + created.push(el); + return el; + }, + addEventListener(name, fn) { + doc.listeners[name] = doc.listeners[name] || []; + doc.listeners[name].push(fn); + }, + querySelectorAll: () => [], + documentElement: makeElement("html", ""), + body: { + // Recorded, and registered under its id: a banner the popup + // prepends is on the page from then on, and a test asking for it + // by id has to find it. + prepend: (el) => { + prepended.push(el); + if (el && el.id) els.set(el.id, el); + }, + appendChild: () => {}, + removeChild: () => {}, + }, + elements: els, + authoredIds: authored, + created, + prepended, + }; + return doc; +} + +async function settle() { + for (let i = 0; i < 50; i++) await Promise.resolve(); +} + +/** + * Boot the popup over `stored`. + * + * @param {*} stored the record storage holds, or undefined for a first run. + * @param {object} [options] + * @param {object} [options.storage] a storage stub from makeStorageStub(), for + * a test that needs to make writes fail or to watch the round trips. + * @returns {Promise} handles onto the booted page. + */ +async function bootPopup(stored, options) { + jest.resetModules(); + + // The three modules that reach the network. None is on the path under + // test; all would make the suite hit the internet. + jest.doMock("../../src/shared/prices", () => ({ + prices: {}, + refreshPrices: jest.fn(async () => {}), + clearPrices: jest.fn(), + getPrice: () => null, + formatUsd: () => "", + formatAddressTotal: () => "", + getAddressValue: () => ({ usd: null, partial: false }), + getWalletValue: () => ({ usd: null, partial: false }), + getTotalValue: () => ({ usd: null, partial: false }), + })); + jest.doMock("../../src/shared/balances", () => ({ + fetchTokenBalances: jest.fn(async () => []), + refreshBalances: jest.fn(async () => {}), + lookupTokenInfo: jest.fn(async () => null), + getProvider: () => ({}), + scanForAddresses: jest.fn(async () => []), + })); + jest.doMock("../../src/shared/transactions", () => ({ + fetchRecentTransactions: jest.fn(async () => []), + filterTransactions: () => [], + })); + + const storage = + (options && options.storage) || + makeStorageStub(stored === undefined ? {} : { autistmask: stored }); + const document = makeDocument(POPUP_HTML); + const reloads = []; + + globalThis.chrome = { + storage: { local: storage.local }, + runtime: { + sendMessage: jest.fn(async () => ({})), + getURL: (p) => "chrome-extension://autistmask/" + p, + onMessage: { addListener: () => {} }, + }, + }; + globalThis.document = document; + globalThis.window = { + location: { + search: "", + href: "chrome-extension://autistmask/src/popup/index.html", + reload: () => reloads.push(Date.now()), + }, + matchMedia: () => ({ + matches: false, + addEventListener: () => {}, + removeEventListener: () => {}, + }), + addEventListener: () => {}, + }; + // The 10s refresh loop init() starts would outlive the test. + const realSetInterval = globalThis.setInterval; + globalThis.setInterval = () => 0; + + require("../../src/popup/index"); + + const booted = []; + for (const fn of document.listeners.DOMContentLoaded || []) { + booted.push(fn()); + } + + // What the browser console would have shown. A throw out of init() is the + // blank popup issue 311 is about, so it is captured rather than thrown: + // the assertion that matters is what ended up on screen. + const pageErrors = []; + for (const p of booted) { + try { + await p; + } catch (e) { + pageErrors.push(String((e && e.message) || e)); + } + } + await settle(); + + globalThis.setInterval = realSetInterval; + + return { + storage, + document, + pageErrors, + reloaded: () => reloads.length, + node: (id) => document.getElementById(id), + text: (id) => { + const el = document.getElementById(id); + return el ? el.textContent : null; + }, + value: (id) => document.getElementById(id).value, + hidden: (id) => + document.getElementById(id).classList.contains("hidden"), + click: async (id) => { + const el = document.getElementById(id); + const fns = el.listeners.click || []; + for (const fn of fns) await fn(); + await settle(); + }, + settle, + // The view ids whose section is not hidden, as the audit measured them. + visibleViews: () => { + const out = []; + for (const [id, el] of document.elements) { + if (!id.startsWith("view-")) continue; + if (!el.classList.contains("hidden")) out.push(id.slice(5)); + } + return out; + }, + }; +} + +function cleanupPopup() { + delete globalThis.chrome; + delete globalThis.document; + delete globalThis.window; +} + +module.exports = { + bootPopup, + cleanupPopup, + settle, + unversionedValidProfile, + ADDRESS, + TOKEN_ADDRESS, + POPUP_HTML, +};