diff --git a/README.md b/README.md index ea5fc85..109a69b 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,76 @@ # AutistMask -AutistMask is a GPL-licensed JavaScript browser extension by -[@sneak](https://sneak.berlin) that provides a minimal Ethereum wallet for -Chrome and Firefox. It manages HD wallets derived from BIP-39 seed phrases and -supports sending and receiving ETH and ERC-20 tokens, as well as web3 site -connection and authentication via the EIP-1193 provider API. +AutistMask is a GPL-3.0-licensed browser extension by +[@sneak](https://sneak.berlin): an Ethereum wallet for Chrome and Firefox. -The most popular browser-based EVM wallet has a cute mascot, but sucks now. It -has tracking, ads, preferred placement for swaps, tx broadcast fuckery, -intercepts tx status links to their own site instead of going to Etherscan, etc. -None of the common alternatives work on Firefox. +AutistMask does four things: -Hence, a minimally viable ERC20 browser wallet/signer that works cross-platform. -Everything you need, nothing you don't. We import as few libraries as possible -and don't implement any crypto. By default the wallet sends user-specific data -only to the Ethereum RPC endpoint. The user can change that endpoint in -Settings. The endpoint defaults to a public node. +- It holds wallets made from a recovery phrase, a private key or an extended + private key. +- It shows the ETH and ERC-20 token balances of each address. +- It sends and receives ETH and ERC-20 tokens. +- It connects to web3 sites and signs what the user approves. -Out of the box the extension contacts one service, the configured RPC node. The -wallet uses the node for: +It does nothing else. It has: -- blockchain interactions -- USD prices on mainnet, read from Chainlink price feed contracts +- no swap screen; +- no portfolio view; +- no NFT gallery; +- no advertising; +- no tracking. -Two more services are sent none of the user's data until the user ticks a -checkbox for each. Welcome and Settings both offer the two checkboxes: +## Rationale -- "Use Blockscout": a Blockscout block-explorer API, used for: - - transaction history - - the tokens the user has not tracked - - transaction details -- "Check recipients on Etherscan": a best-effort Etherscan label lookup of the - recipient during transaction confirmation +Popular browser wallets have grown: -A community-maintained phishing domain blocklist is built into the extension at -build time and checked locally. Nothing is fetched for it at runtime. +- swap screens; +- portfolio dashboards; +- promotions; +- tracking. -In the extension is a hardcoded list of the top ERC20 contract addresses. You -can add any ERC20 contract by contract address if you wish, but the hardcoded -list exists to detect symbol spoofing attacks and improve UX. +Most of them run only on Chromium browsers. AutistMask is a wallet and nothing +more. One code base serves both Chrome and Firefox. + +## What it talks to + +The extension runs entirely in the browser. No server is run by the developer. + +By default the extension contacts one service: the Ethereum RPC endpoint set in +Settings. The wallet uses that endpoint to: + +- read balances; +- look up ENS names; +- estimate fees; +- broadcast transactions; +- check receipts; +- on Ethereum Mainnet, read USD prices from Chainlink price feed contracts. + +The RPC endpoint sees every address that the wallet queries and every +transaction that the wallet sends. A user who wants privacy can point the RPC +URL at a node of their own, and the Blockscout URL at a Blockscout server of +their own. + +Two more services receive the user's data only after the user turns on a +setting. Both settings are off in a new install. The Welcome screen and Settings +both offer them. + +- "Use Blockscout" sends the user's addresses, and the hash of each transaction + the user opens, to the Blockscout API set in Settings. In return the wallet + shows transaction history, transaction details and the tokens the user has not + tracked. +- "Check recipients on Etherscan" sends the recipient's address to Etherscan + each time the user reviews a send. The wallet warns when Etherscan labels the + address as phishing, scam or exploiter. + +The phishing domain list is built into the extension. Checking a site against it +sends nothing anywhere. + +The section External services below lists each request. ## Getting Started +Build the extension from a clone: + ```bash git clone https://git.eeqj.de/sneak/autistmask.git cd autistmask @@ -49,26 +78,71 @@ make setup make build ``` -`make setup` is the entrypoint for a fresh clone: it installs dependencies from -the lockfile and installs the git pre-commit hook. +`make setup` installs the dependencies from the lockfile and installs the git +pre-commit hook. `make build` writes the extension into `dist/chrome/` and +`dist/firefox/`. -Load the extension: +Load it: -- **Chrome**: Navigate to `chrome://extensions/`, enable "Developer mode", click - "Load unpacked", and select the `dist/chrome/` directory. -- **Firefox**: Navigate to `about:debugging#/runtime/this-firefox`, click "Load - Temporary Add-on", and select `dist/firefox/manifest.json`. Read - [Installing on Firefox](#installing-on-firefox) before relying on this: a - temporary add-on does not survive closing the browser. +- Chrome: + 1. Open `chrome://extensions/`. + 2. Turn on "Developer mode". + 3. Click "Load unpacked". + 4. Pick `dist/chrome/`. +- Firefox: open `about:debugging#/runtime/this-firefox`, click "Load Temporary + Add-on" and pick `dist/firefox/manifest.json`. A temporary add-on is unloaded + when Firefox exits. Read Installing on Firefox below before relying on it. -### Release Artifacts +The user guide, [`docs/README.md`](docs/README.md), explains how to use the +wallet. The release procedure is in [`docs/RELEASE.md`](docs/RELEASE.md). -`make package` runs `make build` and then writes one self-contained, versioned -archive per browser into `release/`, plus a `SHA256SUMS` for them: +### Installing on Chrome -```bash -make package -``` +`manifest/chrome.json` carries a fixed `key`. The key is the public half of an +RSA key pair. Chrome derives the extension id from it, so the id is +`gipbhkogfopeahplcjhipkgpcimdpkip` wherever the extension is loaded from. + +Without a `key`, Chrome derives the id from the directory the extension was +loaded from. `chrome.storage.local`, which holds the wallet, is kept per +extension id. A copy loaded from another directory would then start with empty +storage. Changing `key` changes the extension id, and Chrome then opens the +extension on a new, empty storage area. + +The private half of the key pair is a credential. It is not in this repository, +and nothing here needs it. It signs a CRX, Chrome's packaged extension file, +which this repository does not produce. + +### Installing on Firefox + +`manifest/firefox.json` requires Firefox 115 or newer. + +The `.xpi` (Firefox's add-on package file) that `make package` writes is not +signed. Release Firefox and Firefox ESR (the extended support release) refuse to +install an unsigned add-on, and `xpinstall.signatures.required` has no effect on +them. A permanent install of the unsigned file needs Firefox Developer Edition, +Nightly or an unbranded build (Mozilla's build without the Firefox branding). On +those, `xpinstall.signatures.required` must be set to `false` in `about:config`. + +Every Firefox, release builds included, can load the extension as a temporary +add-on from `about:debugging`. A temporary add-on is unloaded when Firefox +exits, so it must be added again on every start. + +The wallet survives that restart. `manifest/firefox.json` declares a fixed +`browser_specific_settings.gecko.id`, and Firefox keys the extension's storage +on that id. Adding the add-on again in the same profile finds the stored +wallets. + +Removing the add-on in `about:addons` destroys its storage, and the wallets with +it. On Firefox, Remove is final. The recovery phrase, private key or extended +private key is the only way back. + +AutistMask does not run in Firefox private windows. `manifest/firefox.json` sets +`"incognito": "not_allowed"`. If Firefox is set to never remember history, every +window is private, and the extension does not run at all. + +### Release archives + +`make package` runs `make build` and then writes three files into `release/`: ``` release/autistmask-chrome-.zip @@ -76,2952 +150,1445 @@ release/autistmask-firefox-.xpi release/SHA256SUMS ``` -Nothing is published by this. Tagging, CRX packing and any upload are -outward-facing acts and are the owner's alone. The full procedure that turns a -green `main` into a tagged, packaged release — the order of steps, who performs -each, and how to check it worked — is in [docs/RELEASE.md](docs/RELEASE.md). - -The archives are deterministic — entries sorted, timestamps fixed, compression -level fixed — so two builds of one commit produce byte-identical files and the -recorded digest is a property of the input rather than of the clock. - -**Self-containment is checked, not assumed.** `build.js` writes the compiled -Tailwind output to `dist/styles.css` at the `dist/` ROOT, outside both browser -directories, and copies it into each of them as `src/popup/styles.css`; a naive -`zip -r dist/chrome` is therefore correct only by accident. So the packager -resolves every path referenced by the manifest and by every HTML document in the -archive, requires each to be inside the archive, and fails on any reference that -climbs out of the extension root. Files left at the `dist/` root are printed as -deliberately not shipped rather than dropped by a glob. The archive is then read -back off disk and compared member by member against the directory it was built -from: an archive nobody opened is a claim, not an artifact. - -There is one version, and the build enforces it. `package.json`, -`manifest/chrome.json` and `manifest/firefox.json` each declare one and none is -derived from another — the manifests are copied to `dist/` verbatim, which is -what `tests/manifest.test.js` asserts — so `script/lib/version.js` requires all -three to agree and **fails the build when they do not**, naming each file and -what it said. `tests/version.test.js` covers that rule in `make check`. - -### Installing on Chrome - -`manifest/chrome.json` carries a fixed `key`: the public half of an RSA keypair, -base64-encoded DER. It exists for one reason. An unpacked Chrome extension with -no `key` gets an extension id derived from the **absolute path it was loaded -from**, and `chrome.storage.local` — which is where the wallet lives — is -partitioned by that id. Move the checkout, re-clone it, or load a second copy -from anywhere else, and the extension comes up on a fresh, empty storage -partition: the wallet is simply gone, with no error and nothing in the UI to say -so. With the `key` in place the id is derived from the key instead, and follows -the extension wherever it is loaded from. That id is -`gipbhkogfopeahplcjhipkgpcimdpkip`, pinned in `tests/extensionId.test.js` and -observed against a real Chrome in `tests/e2e/storagePartition.js`. - -**Changing `key` changes the extension id, and orphans every wallet stored under -the old one.** It is a migration, not an edit. - -The **private** half is a credential. It is not in this repository, no target -generates one into the working tree, `*.pem` and `*.key` are gitignored, and -`tests/extensionId.test.js` fails if such a file is ever committed. It is not -needed to build, load or test anything here — it signs a CRX, and this repo does -not pack one. A packer would take it from outside the repo, e.g. -`chrome --pack-extension=dist/chrome --pack-extension-key=`. - -### Installing on Firefox - -**The XPI this repo produces is UNSIGNED, and release Firefox and Firefox ESR -will refuse to install it.** Those builds enforce add-on signing with no working -override — `xpinstall.signatures.required` does nothing on them — so a permanent -install needs Firefox Developer Edition, Nightly, or an Unbranded build, with -`xpinstall.signatures.required` set to `false` in `about:config`. - -Signing means submitting to AMO (self-distribution is enough, and does not -require listing), which needs credentials this repository does not have and is -the owner's decision. - -The other route is `about:debugging#/runtime/this-firefox` -> "Load Temporary -Add-on", which works on every Firefox including release. **A temporary add-on is -unloaded when Firefox exits**, so daily use means re-adding it by hand on every -browser start. - -**The wallet survives the restart.** `manifest/firefox.json` declares a fixed -`browser_specific_settings.gecko.id`, and Firefox keys the extension's storage -area on that id rather than on the install, so adding the temporary add-on again -in the same profile finds the vault where it left it. That is asserted, not -assumed: `tests/e2e/firefox/reinstall.js` installs the packaged XPI in a real -Firefox, creates a wallet through the UI, quits the browser, starts it again on -the same profile, adds the add-on again, and decrypts the vault with the -original password back to the original recovery phrase. The `moz-extension://` -origin the popup is served from is _not_ stable across installs and does not -need to be — nothing durable is keyed on it. - -**Removing the add-on does not.** An explicit uninstall — about:addons "Remove" -— destroys the extension's storage, and the vault with it. That is ordinary, -correct browser behaviour and it is observed in the same suite, but for a wallet -it is worth saying out loud: **on Firefox, Remove is irreversible, and the -recovery phrase is the only way back.** - -### Debug Builds - -`make build` never hands back a debug build. `make build-debug` is the only -target that produces one: - -```bash -make build-debug -``` - -`AUTISTMASK_DEBUG=1` still selects the debug compile, and only the exact value -`1` does; any other value (including unset, empty, or `true`) yields a release -build, so a typo cannot accidentally ship the debug behavior. But it is the -compiler's input, not the verifier's: if it happens to be exported in the shell -that runs `make build`, that target compiles a debug bundle and then **fails**, -because it tells `script/verify-build` in so many words that it was supposed to -produce a release build. It used to be that the verifier read the same variable -out of its own environment, agreed with itself, and reported a debug artifact as -verified. The build prints which mode it used. A release build that fails also -**removes `dist/`**, and says so: the bundle it had already written is loadable, -and a loud failure is no protection against someone loading `dist/chrome/` -anyway. `make build-debug` keeps its `dist/` on failure — that output is not -mistakable for a release build, and it is the evidence of what went wrong. See -the [DEBUG Mode Policy](#debug-mode-policy) for what the flag changes. **Never -distribute a debug build** — every wallet it creates gets the same publicly -known test recovery phrase. - -Both targets end by running `script/verify-build`, which reads the compiled -`DEBUG` state back out of the emitted bundles and fails the build if it is not -the one that was asked for. The test suite cannot check this: it loads -`src/shared/constants.js` outside a bundle, so it only ever sees the fallback -value. The assertion is on the artifacts because that is where the property -lives. - -### Build Receipts - -`build.js` records every file it emits — path, sha256, and whether the file is -one of the bundles containing `src/shared/constants.js` — into a build receipt, -and `script/verify-build` checks `dist/` against that receipt: every recorded -file present with exactly the recorded bytes, every audited bundle carrying the -requested `DEBUG` marker, and no regular file or symlink under `dist/` that the -build did not write. The `Makefile` creates the receipt path with `mktemp` per -invocation, outside the repo, and deletes it afterwards. - -That is what ties the check to a build rather than to a directory. What it -establishes is narrow and worth stating exactly: `dist/` is byte for byte the -output of the `build.js` run that just finished, with no regular file or symlink -added, removed or altered in between. Regular files and symlinks are the whole -of what the tree walk covers; fifos, sockets, device nodes and empty directories -under `dist/` are not checked, because a build emits none of them, none can -carry a shippable payload, and `grep` on a fifo would hang rather than fail. It -establishes nothing about whether the source tree or `build.js` were honest, and -it offers nothing to someone handed a `dist/` from elsewhere — without the -receipt from its own build there is no input to the check. Verifiable provenance -for a third party is signing, which this is not. - -There is deliberately no target that re-verifies an existing `dist/` on its own. -The list of files to check has to come from the build that produced them; read -back out of `dist/`, it is the artifact vouching for itself, which is how a -26-byte file containing only the marker string, a hostile content script, and an -entire hand-written `dist/` all used to verify green. - -## Entrypoints - -This repository adheres to the -[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) -standard: normalized scripts in `script/` are the entrypoints for the -development workflow, and the Makefile targets are thin shims that call them. We -provide: - -- `script/bootstrap` — install all dependencies (pinned node via nvm if needed, - yarn via corepack, `yarn install --frozen-lockfile`), then fail, naming the - package, if node cannot find a package listed in `dependencies` or - `devDependencies` of `package.json` -- `script/setup` — make a fresh clone ready for development: bootstrap plus the - git pre-commit hook -- `script/projectname` — print the project name (used for the Docker image tag) -- `script/test` — build the Dockerfile's `test` phase, uncached: the jest suite, - stopped after 90 seconds and rerun verbose if it fails, then - `script/test-verify-build` -- `script/test-e2e` — run the Chrome browser end-to-end suite (docker is the - only prerequisite: it builds a pinned image that carries the repo and a fresh - extension build, see [End-to-End Tests](#end-to-end-tests)) -- `script/test-e2e-firefox` — run the Firefox browser end-to-end suite (same, - against an image with a pinned Firefox and geckodriver, see - [End-to-End Tests](#end-to-end-tests)) -- `script/lint` — build the Dockerfile's `lint` phase, uncached: ESLint - (`eslint.config.js`), `prettier --check`, then `script/check-censored`, - failing on any of them. It never writes: `--fix` is not in this path, so - `make check` stays non-mutating. Linting runs only in the container, because - an ESLint result that depends on whichever ESLint the host happens to have is - not a result, so docker is required to lint. -- `script/fmt` — format all files (writes), on the host, with the `prettier` - that `script/bootstrap` installs in `node_modules/`; when that one is missing - it fails and says to run `script/bootstrap`, instead of using a `prettier` - from the `PATH` -- `script/fmt-check` — check formatting (read-only), on the host, with the same - `prettier` and the same refusal -- `script/check` — run `script/test`, `script/lint` and `script/fmt-check` -- `script/check-censored` — assert the competitor name RULES.md bars appears - nowhere in the working tree or under `dist/` outside its documented - exceptions: the pinned source reference in `script/vendor-blocklist`, the two - provider-shim identifiers in `src/content/inpage.js`, and one ERC-20's - on-chain name in `src/shared/tokenList.js` and in - `script/lib/token-enrichment.json`, the fetched data that file's names are - written from. Each is scoped to the paths named and fails anywhere else. Run - by the `lint` phase, so part of `make check`; it inspects `dist/` when there - is one and says loudly when there is not, and the build context of that phase - never has one. `make build` re-runs it with `--require-dist`, so a build - artifact is always covered -- `script/package` — produce the release artifacts: `make build` first, so the - archives can only ever be made from a `dist/` that has been verified against - that build's own receipt as a RELEASE build, then one self-contained, - versioned archive per browser into `release/` (see - [Release Artifacts](#release-artifacts)). It packages and does not publish -- `script/vendor-blocklist` — refresh `src/shared/phishingBlocklist.json` from - its upstream, pinned to a commit and to the sha256 of the bytes that commit - serves. Run deliberately, never as part of a build: the output is committed - and there is no runtime fetch, so the shipped list is as fresh as the last - vendoring run that was released -- `script/verify-build --expect release|debug --receipt PATH` — assert that the - regular files and symlinks under `dist/` are exactly what the build that just - ran emitted (other file types are out of scope), and that the compiled `DEBUG` - state of the bundles in it is the one that was asked for. Both arguments are - required and neither has a default: the expected mode is stated by the caller - rather than read from `AUTISTMASK_DEBUG`, and the file list comes from the - build's receipt rather than from `dist/` (see - [Build Receipts](#build-receipts)). Run automatically at the end of - `make build` and `make build-debug`; fails loudly rather than passing whenever - it cannot determine something. Not part of `make check`, which does not depend - on build artifacts existing. -- `script/discard-dist-on-failure COMMAND [ARG...]` — run one step of the - **release** build and, if it fails, remove `dist/` before returning that - step's exit status, saying on stderr that it did and why. Every step of - `make build` runs through it; `make build-debug` runs none of them through it. - A step that succeeds removes nothing, and a removal that cannot be completed - is reported as loudly as one that was. -- `script/test-verify-build` — exercise every failure mode of - `script/verify-build` against a fixture tree in a temp dir, asserting the exit - status and the message of each, assert the state of `dist/` on disk after a - failing and a succeeding release build step, and read the `make build` and - `make build-debug` recipes back out of `make -n` to check that they pass the - mode as an argument on a scrubbed environment and wrap only the release path. - Run by the `test` phase, so part of `make check`; it reads no build artifacts - and writes nothing under `dist/`. The cases that depend on file permissions - cannot mean anything for a process that is not subject to them, so the harness - proves its runner against a mode-000 file before counting them, dropping to an - unprivileged user when run as root; if it cannot, it skips those cases and - says so in a banner rather than passing them. -- `script/docker` — build the Docker image, uncached and tagged via - `script/projectname`: the `lint` and `test` phases, then `make build` in the - last stage. It passes what `git describe --tags --always --dirty` prints on - the host as the `VERSION` build argument, or `unknown` when that prints - nothing, and the last stage fails on `unknown` whenever the build context - carries `.git`. Only a build given no `VERSION` at all, such as a plain - `docker build .`, runs `git describe` on the `.git` in the build context, - which `.dockerignore` sends without its `config`, and fails if git cannot read - it -- `script/cibuild` — CI entrypoint: `script/bootstrap`, `script/check`, then the - same image build as `script/docker` -- `script/precommit` — run by the git pre-commit hook; runs `script/check` -- `script/install-precommit` — install the git pre-commit hook - -The Makefile shims to those. It also carries a few targets that have no -`script/` counterpart and are Makefile-only conveniences: - -- `make install` — `yarn install --frozen-lockfile` on its own, without the rest - of `script/bootstrap`. Frozen so a stale `yarn.lock` fails instead of being - silently rewritten. Use `make setup` for a fresh clone. -- `make hooks` — shims to `script/install-precommit` -- `make build` — build the extension into `dist/chrome/` and `dist/firefox/`, - then verify the result against the build's receipt as a release build. A - failure at any step removes `dist/` -- `make build-debug` — the same build with `AUTISTMASK_DEBUG=1`, verified as a - debug build, and keeping its `dist/` on failure (see - [Debug Builds](#debug-builds)) -- `make icons` — redraw the toolbar icons in `icons/` from - `script/lib/icons.js`, at every size `manifest/chrome.json` declares. A file - that already holds the drawn image, the same IHDR and every pixel, is left - untouched. Commit the files with the drawing: `make check` fails while their - image differs from what it draws -- `make enrich-tokens` — fetch the name of each token in - `src/shared/tokenList.js` from CoinGecko's coin list and its website from the - `ethereum-lists/tokens` repository, at a pinned commit, into - `script/lib/token-enrichment.json`, by running `script/lib/enrich-tokens.js`. - CoinGecko's list is live and has no fixed version, so a later run can return - different names -- `make apply-enrichment` — write the names and URLs in - `script/lib/token-enrichment.json` into `src/shared/tokenList.js`, by running - `script/lib/apply-enrichment.js`, taking a URL from the table of project - websites in that script only where the fetched data has none. Commit the list - with the data file: `make check` fails while the list differs from what this - writes. Neither target changes which tokens are on the list, and no build runs - either -- `make clean` — remove `dist/` and `release/` -- `make dev` — run the build `make build` runs, without the checks that follow - it, then run it again after every change to a file under `src/`, `manifest/` - or `icons/`, until interrupted. A failed build is reported, may leave `dist/` - partly written, and watching carries on. It writes no build receipt, so - nothing can verify what it leaves in `dist/` (see - [Build Receipts](#build-receipts)): anything handed on comes from - `make build`. A release build unless `AUTISTMASK_DEBUG=1` is exported. A - change anywhere else, `package.json` and `build.js` included, starts no build, - and a directory created while it runs is not watched; restart it after either - -## End-to-End Tests - -There are two suites, one per browser, and they share no code. Chrome runs on -Playwright; Firefox has its own WebDriver client, because Playwright cannot -observe errors on a Firefox extension page at all — see -[Firefox](#firefox-make-test-e2e-firefox) below. Both require docker, and both -are outside `make check`. Neither opens the popup from the toolbar button: both -load its page in an ordinary tab, so what the toolbar popup itself adds, its -size and its closing when it loses focus, is covered by neither. - -Each run builds its image under a tag of its own, `autistmask-e2e-chrome:` or -`autistmask-e2e-firefox:` followed by twelve random hex digits, so two clones -can run the same suite at once without one build taking the other's image away. -The run removes that image, and its container if one is still running, after the -suite passes or fails, on Ctrl-C, on `kill`, and when the terminal or SSH -session it runs in closes. A run killed outright (`kill -9`) cannot clean up: -its image stays under its tag until removed by hand with `docker image rm`. - -### Chrome (`make test-e2e`) - -`make test-e2e` builds `dist/chrome/` and drives the **real popup in a real -Chrome**, loaded as an unpacked MV3 extension inside a pinned -`mcr.microsoft.com/playwright` container (pinned by digest in -`tests/e2e/Dockerfile`, which is also where the extension is built; docker is -required and the suite fails loudly rather than skipping if it is unavailable). -The suite lives in `tests/e2e/` and is driven by `playwright-core`, whose -version must stay matched to the container's Playwright version — the browsers -ship inside the image. - -It covers popup load, WebAssembly compilation under the shipped CSP (see -[Content Security Policy](#content-security-policy)), wallet creation through -the UI, the Add Token screen, the transaction detail screen for an ERC-20 -transfer, and the recovery phrase screen — which wallet types are offered it, -that it holds nothing before the password is accepted, that a wrong password -reveals nothing, that leaving it by either route wipes it — including a leave -taken while the decrypt is still running — and that reopening the popup does not -land on it. It also covers address removal: which wallets offer the control at -all, that the confirmation states the route back rather than showing an empty -paragraph, that leaving the confirmation removes nothing, and that confirming it -does. All outbound network is intercepted at the browser level and served from -fixtures in `tests/e2e/network.js`, so the run is deterministic and fully -offline; unrecognised outbound requests are reported as failures rather than -silently allowed. - -It also covers the StateRecovery screen, under the shipped CSP: a stored record -this build cannot read opens the popup on it, its export text box holds that -record exactly as stored, a near-miss confirmation phrase erases nothing, and -the exact one erases the record and reloads into Welcome. - -It also covers the **Settings screen**, which holds the densest run of element -id lookups in the codebase and where one wrong id leaves the whole popup blank -rather than only degrading Settings: that the screen renders populated — the -About well and the wallet list are read back, so a `show()` that stopped early -is caught rather than merely a view that failed to appear — that the four Token -Spam Protection controls are real checkboxes defaulted on, and that the theme -and network selectors offer exactly the choices `src/shared/networks.js` and -`index.html` define. What the selectors persist is then driven through the UI to -`dark` and `sepolia` and reasserted after a popup reopen before being restored -the same way; neither value is its selector's first `