# 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.

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.

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,
don't implement any crypto, and don't send user-specific data anywhere but a
(user-configurable) Ethereum RPC endpoint (which defaults to a public node). The
extension contacts three user-configurable services: the configured RPC node for
blockchain interactions, a public CoinDesk API (no API key) for realtime price
information, and a Blockscout block-explorer API for transaction history and
token balances. It also performs best-effort Etherscan address label lookups
during transaction confirmation. A community-maintained phishing domain
blocklist is built into the extension at build time and checked locally; nothing
is fetched for it at runtime.

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.

## Getting Started

```bash
git clone https://git.eeqj.de/sneak/autistmask.git
cd autistmask
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.

Load the extension:

- **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.

### Release Artifacts

`make package` runs `make build` and then writes one self-contained, versioned
archive per browser into `release/`, plus a `SHA256SUMS` for them:

```bash
make package
```

```
release/autistmask-chrome-<version>.zip
release/autistmask-firefox-<version>.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 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=<path to the .pem>`.

### 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`)
- `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` — run the test suite (jest)
- `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` — run ESLint (`eslint.config.js`) and then `prettier --check`,
  failing on either. It never writes: `--fix` is not in this path, so
  `make check` stays non-mutating. Linting runs in the container — the script
  builds the Dockerfile's `lint` stage — because an ESLint result that depends
  on whichever ESLint the host happens to have is not a result. Docker is
  therefore required to lint; inside that image `AUTISTMASK_LINT_NATIVE=1` makes
  the same script lint in place instead of recursing.
- `script/fmt` — format all files (writes)
- `script/fmt-check` — check formatting (read-only)
- `script/check` — run test, test-verify-build, check-censored, lint, and
  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`. Each is scoped to that path and
  fails anywhere else. Part of `make check`, which inspects `dist/` when there
  is one and says loudly when there is not; `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.
  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 tagged via `script/projectname`
- `script/cibuild` — CI entrypoint: plain `docker build .`
- `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 clean` — remove `dist/` and `release/`
- `make dev` — build in watch mode

## 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`.

### 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 **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 `<option>`, so neither can
be read back from the markup with no JavaScript having run. One spam filter is
likewise toggled off and back on across a reopen each way, which exercises the
change handler, `saveState()`, `loadState()` and the assignment `init()` makes.
Each group of these assertions records a coverage key and a final case demands
the exact set, so a section that silently stopped running reddens the suite
instead of quietly shrinking it.

It also covers the confirmation screen, for both a native ETH send and an ERC-20
send: Send disabled while the fee estimate is in flight, enabled once it lands,
the fee block quoting the expected cost and the reserve separately, the distinct
message for an estimate that failed, and the view height staying constant across
every one of those transitions. The load-bearing one is that the spend gate uses
the **reserve** and not the displayed **estimate** — the two are stubbed far
apart on purpose, and the funded and refused sends sit on opposite sides of the
reserve while sitting on the same side of the estimate, so swapping the two in
`src/popup/views/confirmTx.js` fails the suite instead of passing it. That is
what [#154](https://git.eeqj.de/sneak/AutistMask/issues/154) was, and it was
previously correct by reading only.

It also covers the **dApp approval round trips** — the one place where the
content script, the inpage provider, the background worker and the approval
popup all have to work together. A local test page is served by the route
handler on a reserved-TLD origin, gets `window.ethereum` from the shipped
`MAIN`-world content script like any other page, and drives
`eth_requestAccounts`, `personal_sign`, `eth_signTypedData_v4` and
`eth_sendTransaction` through the real prompts. Every signature is recovered in
the runner and compared against the active address, the transaction assertions
run against the raw signed transaction captured at `eth_sendRawTransaction`
rather than against anything the extension reported, rejecting each prompt is
required to return a rejection to the page rather than hang or resolve, and the
password is required to be absent from every message the approval window sends
to the background — with the message that would carry it required to be present,
so that check cannot pass by observing nothing. That last one is the standing
floor under [#157](https://git.eeqj.de/sneak/AutistMask/issues/157).

Two limits of that coverage, neither of them papered over. The RPC is stubbed
throughout, so this is **not** a real dApp against a real network with real
funds; that remains a human pass before 1.0.0. The site-connection prompt is
raised through `chrome.action.openPopup()`, and headless Chromium's
browser-action popup is not a page Playwright can see or click, so that one
prompt is driven at the URL the extension itself puts on the action — the same
page and the same approval id, but whether a real toolbar click shows it is not
observable here.

Any test that drives a failure path on purpose declares the `console.error` it
is about to provoke, via `errors.expect()`. That is not a mute: the declaration
consumes exactly one matching record, and a declaration nothing matched fails
its test just as an undeclared error does.

That reporting has one bound worth knowing. Observation ends when the browser
context is torn down, and nothing can watch traffic after that, so the run keeps
collecting for a fixed grace period after the last test returns
(`TRAILING_WATCH_MS` in `tests/e2e/run.js`, currently 1500ms) and then closes
the context. A request whose _first_ dispatch falls after that window is never
seen at all and cannot fail the run. In practice a request a test fires without
awaiting reaches the route handler about 10ms later, and anything on a repeating
timer gets observed on an earlier tick during the ~20s suite — but a one-shot
call deliberately deferred past the window will escape.

That interception covers the MV3 background service worker as well as the popup
page, which it does not by default — `script/test-e2e` sets
`PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1` for it. Because that flag is
experimental, the harness does not take it on trust. At launch it waits for the
background worker to exist, asks it for one throwaway `fetch()` of its own, and
requires that request to arrive in the route handler within 30 seconds or aborts
the entire suite (`tests/e2e/harness.js`). The anchor used to be the worker's
own startup traffic — the phishing blocklist fetch — and there is no longer any:
the blocklist is vendored at build time and the extension contacts nobody when
it starts. An earlier synthetic probe was rejected because evaluating in an
extension service worker immediately after launch killed the worker outright;
waiting for the worker to be handed over first, and issuing a `fetch()` that is
not awaited, does not. Failing the probe fails closed — the suite refuses to run
rather than passing quietly.

As defence in depth, Chrome is also started with
`--host-resolver-rules=MAP * ~NOTFOUND`, so a request that ever did slip past
the route handler could not resolve a host at all. That only bounds the damage;
detecting escaping traffic remains the canary's job. To see what is actually
being intercepted, run with `E2E_TRACE_NETWORK=1` and every routed request is
printed, tagged `[sw]` or `[page]`.

**Any uncaught page error or `console.error` fails the run.** That is the point:
this suite exists because a `ReferenceError` from a used-but-not-imported
identifier shipped twice, fatal in a browser and invisible to a `make check`
that was `prettier --check` only. ESLint's `no-undef` now catches that exact
class before a browser is involved, so this suite is no longer the only thing
standing between it and a release — but a static rule only sees identifiers, and
the runtime errors this suite catches are broader than one rule.

`make test-e2e` then runs a second program in the same image,
`tests/e2e/storagePartition.js`, which answers where `chrome.storage.local`
lives. It loads the built extension from one temporary directory in a fresh
profile, writes a sentinel key the extension itself never touches, closes the
browser, and loads a **copy at a different path into the same profile** — then
does it again with `key` stripped out of the manifest, and reports what each
pair actually did rather than what it expected. It needs its own browser
sessions, four of them, because the whole subject is what happens ACROSS loads.

The observed behaviour, on the pinned Chromium: **with `key`, both paths get the
same extension id and the second load reads the first load's storage. Without
`key`, the two paths get different ids and the second load sees an empty
partition** — the wallet, silently gone. Loading both keyed copies at once in
one profile yields a single extension id, not two: Chrome does not load a second
copy of an id it already has. The assertions are annotated as observations, so a
Chrome that ever changes this fails the run instead of passing it.

### Firefox (`make test-e2e-firefox`)

`make test-e2e-firefox` builds `dist/firefox/` and drives the **real popup in a
real Firefox**, installed as an unpacked MV2 temporary add-on via geckodriver.
It covers popup load, wallet creation through the UI, the Add Token screen, and
the four dApp round trips — `eth_requestAccounts`, `personal_sign`,
`eth_sendTransaction`, and a closed approval window rejecting with EIP-1193 4001
— driven through the real content script, background page and approval windows.

The suite lives in `tests/e2e/firefox/`. Its WebDriver client (`driver.js`) has
**no npm dependencies at all**: it is built on global `fetch` and
`child_process` against geckodriver's HTTP API. The dApp fixture (`dapp.js`) and
the assertions do use `ethers`, and have to — a signature is recovered in the
runner rather than believed from the extension, and the stub node has to answer
`eth_sendRawTransaction` with the hash `ethers` computes for the artifact it
sent, or `provider.broadcastTransaction()` refuses the answer.

`make test-e2e-firefox` then runs a second program in the same image,
`tests/e2e/firefox/reinstall.js`, and it installs the **packaged XPI** rather
than the unpacked directory — the only place a real Firefox is asked to load the
artifact that would actually be handed to someone. It runs two browsers over one
profile, because it asks two questions that have different answers:

- **Restart.** Install, create a wallet through the UI, record the vault, quit
  the browser, start it again on the same profile, add the add-on again. The
  extension must not come up as a fresh install; the vault, xpub and first
  address must be unchanged; and the vault must still **decrypt** with the
  original password to the original recovery phrase, through the real Show
  Recovery Phrase screen. "The ciphertext is still in storage" and "the wallet
  still works" are different claims and only the second one is worth anything.
  This is what a user does every day, because a temporary add-on is unloaded
  when Firefox exits.
- **Removal.** Then, in the same browser, an explicit uninstall and a fresh
  install. Observed: **Firefox destroys the extension's storage on uninstall**,
  so the vault is gone. Recorded as an assertion, so a Firefox that ever changes
  it fails the run, and stated in
  [Installing on Firefox](#installing-on-firefox) because for a wallet it means
  Remove is irreversible except from the recovery phrase.

It also pins down something worth knowing about the harness: the
`moz-extension://` uuid the popup is served from is not stable across installs,
and navigating to a stale one does not fail — it hangs. The program reads the
live uuid out of `extensions.webextensions.uuids` after every install.

Both suites build their own image, each with the repo and a fresh extension
build baked in; what differs is the base. The Chrome image layers those on top
of a published Playwright image, whereas this one is assembled from a `node`
base, because no published image carries both a pinned Firefox and a matching
geckodriver. `tests/e2e/firefox/Dockerfile` pins all three external artifacts by
digest — the `node` base image, the Firefox 153.0.3 tarball, and geckodriver
0.36.0 — and the Firefox version in particular must not float:
`-remote-allow-system-access` is **mandatory** on 153 and was not on 142.
Without that flag, both navigating to `moz-extension://` and running
chrome-context script fail with `unsupported operation`. The flag grants the
driver full chrome privileges over that browser, which is acceptable only
because it is a throwaway container.

The popup's `moz-extension://` uuid is **pinned, not discovered**: the profile
pref `extensions.webextensions.uuids` maps the extension id that
`manifest/firefox.json` already declares to a fixed uuid, so the popup URL is
deterministic. Navigation uses **classic** WebDriver `POST /session/{id}/url`,
because BiDi's `browsingContext.navigate` refuses `moz-extension://` outright.

**Any uncaught error from a `moz-extension://` source fails the run**, including
errors from the background page, which the suite never navigates to: a `throw`
at the top of `src/background/index.js` kills the background page and fails
step 1. Content scripts **are** exercised now — the dApp steps drive a page
served from loopback, which survives `--network none` — but the _capture_ of a
content-script error by this route is still unproven: no probe has forced a
throw inside one and watched it fail the run, so it remains an expectation
rather than a demonstrated fact. Errors from add-on install and background
startup are folded into step 1 rather than discarded.

An **unhandled promise rejection counts as an uncaught error** on both suites,
which matters because a good deal of popup code is now `async` and called
without an `await`. Demonstrated, not assumed: a `throw` placed past the first
`await` of `approval.show()` — which nothing awaits — turns the
`eth_requestAccounts` step red on Firefox
(`uncaught extension errors during this step`, from the console-service drain)
and on Chrome (`pageerror`), with the rest of the run unaffected because the
approval view had already rendered.

One error is tolerated rather than fatal, listed in `ALLOWED_ERRORS` in
`tests/e2e/firefox/run.js` with the issue that will delete it, and printed on
every occurrence so the concession stays visible in the run output. It is
Firefox reporting the site-approval popup's unawaited `sendMessage` settling
after `window.close()` unloaded the context — the same teardown ordering as
[#275](https://git.eeqj.de/sneak/AutistMask/issues/275), and unsuppressable from
the calling code, because `BaseContext.wrapPromise` reports it whether or not a
handler is attached. Errors are read from the privileged `nsIConsoleService` in
Marionette's chrome context and filtered to non-warning entries whose
`sourceName` is the extension origin. That mechanism is not a stylistic choice.
WebDriver BiDi's `log.entryAdded` delivers **nothing** for extension pages: on a
plain `http://` page it reports uncaught errors with stack traces, and on the
`moz-extension://` popup it reports zero events, because Firefox's remote agent
excludes extension browsing contexts from BiDi observation. Any harness built on
Playwright-BiDi or Puppeteer-BiDi would therefore see nothing and report
success, which is exactly the vacuous check this repo has already shipped twice.
Do not migrate this suite to BiDi.

Two limits are worth knowing, both real differences from the Chrome suite:

- **Error capture is poll-based, not event-streamed.** The console is drained at
  each step boundary, so an error is attributed to the step it was drained
  after, not to a moment within it. The window that is drained runs from add-on
  install to **≈1.5s** after the last step returns — a 500ms settle, a 1000ms
  tail sleep and two drain round trips — and then the browser is torn down. That
  cut-off is not a hard boundary: with throws scheduled at fixed offsets, three
  runs reported everything up to +1.5s and one of the three also reported +1.6s,
  so an error landing near it may or may not be seen, and anything well past it
  is not. Inside the window there is no race — each drain reads and clears the
  console in a single chrome round trip, so an error logged mid-drain lands in
  that batch or the next rather than being destroyed unread — but there is a
  **capacity limit**: `nsIConsoleService` keeps a ring buffer of 250 messages
  and silently evicts the oldest, so more than 250 console messages between two
  drains destroys the excess unread. 400 throws inside one step are reported as
  exactly the newest 250, three runs running. That buffer is shared with
  Firefox's own console noise; a clean run peaks at 4 of 250 at the install
  drain and 0 at every later drain, so the three steps here have wide headroom,
  but a step that logs heavily could evict unread errors. What poll-based costs
  is location, not coverage: an error cannot be placed within a step the way the
  Chrome suite's `pageerror` events place it.
- **Almost nothing is stubbed, which inverts the coverage of network-dependent
  code.** The container still runs with `--network none`, so the run is offline
  and no request can escape. The one thing it can reach is the loopback fixture
  in `tests/e2e/firefox/dapp.js`, which serves the dApp page and a JSON-RPC node
  and which the extension's `rpcUrl` is pointed at for the dApp steps; a
  JSON-RPC method that fixture does not model fails the run rather than
  answering `null`. Everything else — Blockscout, the price feed, the phishing
  blocklist — has no fixture and simply fails, and the extension swallows its
  own fetch failures, so only the _failure_ branches of that code are ever
  executed. A `ReferenceError` in the success path of `renderTransactions`, or
  of price rendering, passes this suite green. The offline run is also weaker
  than the Chrome suite's interception for those calls: it proves nothing got
  out, but it cannot report which requests were attempted.

Neither `make test-e2e` nor `make test-e2e-firefox` is part of `make check` or
`make test`. `REPO_POLICIES.md` caps `make test` at 20 seconds and a browser
suite does not fit; nothing in `tests/e2e/` is named `*.test.js`, so jest cannot
pick it up either. Run them locally before changing anything under
`src/popup/views/`.

### In CI

`.gitea/workflows/e2e.yml` runs both suites on every push, as two jobs —
`e2e-chrome` and `e2e-firefox` — separate from the `check` workflow, so the
20-second `make test` cap and the local fast path are untouched. Each job is a
checkout and the matching `script/` entrypoint, nothing else.

Docker is the only thing either job needs from the runner, and that is not an
accident. The runner executes a job inside a container against the **host's**
docker daemon, so a `docker run -v "$PWD:/work"` source path is resolved by the
host and mounts an empty directory, and the runner image's node is too old to
install this repo's dependencies. Both suites therefore ship the repo to the
daemon as a build context and build the extension inside the image, which works
identically on a laptop.

The jobs **report, they do not gate.** A failure is a red mark against the
commit that a reviewer has to account for, not a hard block: whether a check
blocks a merge is Gitea branch protection, which this repo does not configure.

That is not only a statement about configuration. The Chrome suite is
**measurably flaky under load** — two of six runs of unmutated code on a busy
machine lost the approval popup out from under the dApp signing wait, always in
the `#183` section, tracked as
[#287](https://git.eeqj.de/sneak/AutistMask/issues/287). So a red `e2e-chrome`
has to be read before it is believed, and that flake is the blocker to ever
making this a required check. Do not answer it with a retry wrapper: a suite
that reruns until it is green stops being evidence.

Nothing in either job can pass vacuously. There is no `continue-on-error` and no
`|| true`; both scripts exit non-zero when docker is missing, when the image
build fails, and when the browser fails to start; the Chrome harness aborts the
suite outright if its network interception is not in effect.

Measured on this repo's runner: `e2e-chrome` about 1m55s cold, almost all of it
the one-time pull of the pinned ~800MB Playwright layer, and well under a minute
once that layer is cached. `e2e-firefox` about 1m05s cold, and it caches its
Firefox and geckodriver downloads the same way.

### Element id guard (part of `make check`)

`tests/popupElementIds.test.js` asserts statically that every element id the
popup looks up — `$("...")`, `document.getElementById("...")`,
`showError()`/`hideError()`, and the `view-<name>` a literal `showView("...")`
resolves to — exists in `src/popup/index.html`, and that `index.html` defines no
id twice. A wrong id is valid JavaScript naming a defined function, so neither
jest (node environment, no DOM) nor a linter objects to it; at runtime `$()`
returns `null` and the next property access throws, which inside a view's
`init()` aborts the rest of `src/popup/index.js` `init()` and leaves the popup
blank.

It runs with no browser, so unlike the e2e suites it fits inside `make check`,
and it covers every view rather than the ones some test happens to open. It only
sees literal arguments: a call like `$(containerId)` is invisible to it, and a
lookup naming the wrong existing element is valid by construction. Both of those
are the browser suites' job.

## Rationale

Common popular EVM wallets have become bloated with swap UIs, portfolio
dashboards, analytics, tracking, and advertisements. It is no longer a simple
wallet. Most alternatives only support Chromium browsers, leaving Firefox users
without a usable option.

AutistMask exists to provide the absolute minimum viable Ethereum wallet
experience: manage seed phrases, derive HD addresses, send and receive ETH and
ERC-20 tokens, and connect to web3 sites. Nothing else. No swaps (that's what
the web is for), no analytics, no tracking, no ads, no portfolio views, no NFT
galleries. Just a wallet.

## Design

AutistMask is a browser extension targeting both Chrome (Manifest V3) and
Firefox (Manifest V2/V3 as supported). The codebase is shared between both
targets with platform-specific manifest files and a build step that produces
separate output directories.

### Architecture

```
src/
  background/         — service worker / background script
    index.js          — RPC routing, approval flows, message signing
  content/            — content script injected into web pages
    index.js          — relay between inpage provider and background
    inpage.js         — the window.ethereum provider object (EIP-1193)
  popup/              — popup UI (the main wallet interface)
    index.html
    index.js          — entry point, view routing, state restore
    styles/main.css   — Tailwind source
    views/            — one JS module per screen (home, send, approval, etc.)
  shared/             — modules used by both popup and background
    alarms.js         — recurring background jobs (extension alarms API)
    balances.js       — ETH + ERC-20 balance fetching via RPC + Blockscout
    constants.js      — chain IDs, default RPC endpoint, ERC-20 ABI
    ens.js            — ENS forward/reverse resolution (popup only)
    prices.js         — ETH/USD and token/USD via CoinDesk API
    scamlist.js       — known fraud contract addresses
    state.js          — persisted state (extension storage)
    symbolSpoof.js    — the known-symbol spoof rule, shared by all surfaces
    tokenList.js      — top ERC-20 tokens by market cap (hardcoded)
    transactions.js   — tx history fetching + anti-poisoning filters
    uniswap.js        — Uniswap Universal Router calldata decoder
    vault.js          — password-based encryption via libsodium
    wallet.js         — mnemonic generation, HD derivation, signing
manifest/
  chrome.json         — Manifest V3 for Chrome
  firefox.json        — Manifest V2 for Firefox
```

### Background scheduling

Chrome runs `src/background/index.js` as a Manifest V3 service worker, which the
browser terminates after roughly 30 seconds idle and re-evaluates from scratch
on the next event. Two consequences shape every recurring job in the background:

- `setInterval` and `setTimeout` are useless. They are destroyed with the
  worker, so a job scheduled that way runs until the first idle period and never
  again. The one recurring job — the 60-second balance refresh — is scheduled
  through the extension alarms API (`src/shared/alarms.js`) instead. The browser
  holds the schedule and wakes the worker to deliver it. Alarm periods are
  clamped to a one-minute minimum, so the balance refresh is expressed as
  exactly one minute and nothing is silently slowed down.
- Module-level variables do not survive either. Anything that must be remembered
  across a restart goes in extension storage. `localStorage` does not exist in a
  service worker at all — the one remaining user of it, `src/shared/ens.js`,
  runs only in the popup and is marked as such.

The job also carries a freshness guard, and a guard must never be timed to the
alarm period it gates. The guard is measured from the moment the last run
finished, which is one run-duration after the alarm that started it, so a guard
of exactly one period vetoes the very next tick and the real cadence becomes two
periods. The balance refresh guard exists to skip work an open popup has already
done — the popup refreshes every 10 seconds and stamps the same field — and that
has to keep applying on the scheduled tick, so the guard is shortened to half
the alarm period rather than bypassed: comfortably above the popup's 10 seconds,
so an open popup still suppresses the background job, and comfortably below the
60-second period, so the schedule always wins.

Retiring a job means clearing its alarm, not just deleting its handler. The
browser keeps an alarm until something removes it, so an install that once ran
the version which created it goes on being woken on that schedule forever. Names
that are no longer handled are listed in `OBSOLETE_ALARMS` and cleared on every
start; the 24-hour phishing blocklist refresh is there, retired when the runtime
fetch was removed.

The startup path (`ensureRecurringAlarms()`) runs on `onInstalled`, on
`onStartup`, and at the top level of the worker, so every way the background
context can start re-establishes the schedule. On a fresh install more than one
of those fires, so they share a single in-flight run rather than racing. It is
idempotent: an alarm that already exists with the period the code asks for is
left alone, because re-creating one restarts its schedule and a busy extension
would push the next fire out indefinitely. An alarm carrying a different period
— one created by an earlier version — is re-created once, or a period changed in
a new release would never reach an existing install.

Nothing is fetched when the worker starts. A wake costs no network traffic at
all, which is what the phishing blocklist being vendored at build time bought.

Firefox uses Manifest V2 with a persistent background page, where timers would
survive. Both browsers are built from one bundle and both take the alarm path,
so there is a single code path to reason about; `"alarms"` is declared in both
`manifest/chrome.json` and `manifest/firefox.json`.

### UI Design Philosophy

The UI is inspired by _Universal Paperclips_. It's deliberately minimal,
monochrome, fast, and includes once-popular usability affordances that seem to
have fallen out of fashion in modern UI design. Clickable things look clickable.
Things don't flash or spin or move around unnecessarily. This is a tool for
getting work done, not a toy.

This is designed for a normal audience. Basic familiarity with cryptocurrency
terms is required, but you need not be a programmer or software engineer to use
this wallet.

If you _are_ basically familiar with cryptocurrency terms, you should be able to
use all of the main features of this wallet without having to read the
documentation; i.e. we wish for the primary functionality to remain easily
discoverable.

#### Visual Style

- **Monochrome**: Black text on white background. Color is only used when and
  where it is semantically meaningful and explicitly useful, such as error
  messages, critical warnings, or address disambiguation. (Notable exception: we
  use color dots, and identicons, to help a user easily distinguish addresses.)
- **Text-first**: Every piece of information is presented as text. Balances are
  numbers. Addresses are hex strings. Flash messages are sentences. All fiddly
  bits can be clicked to copy to the clipboard, and external links to Etherscan
  are provided everywhere they might be useful.
- **Monospace font**: All text is rendered in the system monospace font.
  Ethereum addresses, transaction hashes, and balances are inherently
  fixed-width data. Rather than mixing proportional and monospace fonts, we use
  monospace everywhere for visual consistency and alignment.
- **No images**: Zero image assets in the entire extension. No logos, no
  illustrations, no token icons. Token identity is conveyed by symbol text (ETH,
  USDC, etc.). We display
  [Blockie identicons](https://github.com/MyCryptoHQ/ethereum-blockies-base64)
  on critical screens and when space is available to allow users to disambiguate
  addresses visually, as a security feature.
- **Tailwind CSS**: Utility-first CSS via Tailwind. No custom CSS classes for
  styling. Tailwind is configured with a minimal monochrome palette. This keeps
  the styling co-located with the markup and eliminates CSS file management.
- **Vanilla JS**: No framework (React, Vue, Svelte, etc.). The popup UI is small
  enough that vanilla JS with simple view switching is sufficient. A framework
  would add bundle size, build complexity, and attack surface for no benefit at
  this scale.
- **360x600 popup**: Standard browser extension popup dimensions. The UI is
  designed for this fixed viewport.

#### No Layout Shift

Asynchronous state changes (clipboard confirmation, transaction status, error
messages, flash notifications, API results returning) must _never_ move around
the existing UI elements. All dynamic content areas must reserve their space up
front using `min-height` or always-present wrapper elements.
`visibility: hidden` is preferred over `display: none` when the element's space
must be preserved. This prevents jarring content jumps that disorient users and
can cause dangerous mis-clicks. Anyone who has multi-tabled on ClubGG and
smashed the big red "all-in blind preflop" button when trying to simply "call"
on a different table knows exactly tf I am talking about.

#### Clickable Affordance

Every interactive element must visually indicate that it is clickable. Buttons
use a visible border, padding, and a hover state (invert to white-on-black).
Text that triggers an action (e.g. "Add additional wallet...") uses an
underline. No invisible hit targets, no bare text that happens to have a click
handler. If it does something when you click it, it must look like it does
something when you click it.

#### Display Consistency

The same data must be formatted identically everywhere it appears. Token and ETH
amounts are displayed with exactly 4 decimal places (e.g. "1.0500 ETH", "17.1900
USDT") in balance lists, transaction lists, send confirmations, and approval
screens. Timestamps include both an ISO datetime and a humanized relative age
wherever shown. If a formatting rule applies in one place, it applies in every
place. Users should never see the same value rendered differently on two
screens.

**Specific Exception — Truncation:** On some non-critical display locations, we
may truncate _a small number_ of characters from the middle of an address solely
due to display size constraints. Wherever possible, and, notably, **in all
critical contexts (transaction confirmation view before signing, transaction
history detail view) addresses will _NEVER_ be truncated**. Even in places we
truncate addresses, we truncate only a maximum of 10 characters, which means
that the portions still displayed will be more than adequate for the user to
verify addresses even in the case of address spoofing attacks. Clicking an
address will always copy the full, untruncated value.

**Specific Exception — Transaction Detail view:** The transaction detail screen
is the authoritative record of a specific transaction and shows the exact,
untruncated amount with all meaningful decimal places (e.g. "0.00498824598498216
ETH"). It also shows the native quantity (e.g. "4988245984982160 wei") below it.
Both are click-copyable. Truncating to 4 decimals in summary views is acceptable
for scannability, but the detail view must never discard precision — it is the
one place the user can always use to verify exact details.

**Specific Exception — nonzero floor on the approval screens:** A nonzero amount
must never render as zero. Truncating to 4 decimals does exactly that to an
amount below 0.0001 — 1 base unit of an 18-decimal token, 500 base units of an
8-decimal one — and on the dApp approval screen and the wait/success/error
screens that carry its amount forward, a real transfer or allowance then reads
as "nothing is being moved". A swap's `Min. received` is the sharper case: a
slippage floor shown as `0.0000` states that the swap may return nothing.

On those screens, when the truncated string would contain no digit from 1 to 9
and the value does, the amount is extended to its first significant digit
instead: `0.000000000000000001 DAI`, not `0.0000 DAI`. The test is on the whole
truncated string, integer part included, so `1.00005` still shows as `1.0000` —
the exception only fires where the entire displayed figure would read as zero. A
genuine zero still renders `0.0000`, and truncation stays truncation: `0.99999`
shows as `0.9999`, never rounded up.

The rule and its exception live in `src/shared/amountDisplay.js` as
`truncateAmount()` and `truncateAmountNeverZero()`. Everything the approval and
confirmation screens display goes through the floored one — the ERC-20 amount,
the ETH value and max fee (`src/popup/views/approval.js`), and the swap's
`Amount` and `Min. received` lines (`src/shared/uniswap.js`). The history and
balance lists (`src/shared/transactions.js`) use the unfloored one: the
transaction detail view is the authoritative record and already shows exact
precision. The 4-decimal rule is unchanged everywhere else, including for
amounts at or above the floor on the approval screens.

The floor applies only where the token's scale is known. Where it is not, the
approval screen states base units instead of a quantity — see Unknown token
scale below — and no truncation happens at all.

**Specific Exception — unknown token scale:** Calldata carries base units and no
scale, so every amount on the dApp approval screen needs the token's `decimals`.
It is resolved from the bundled token list, then from the tokens the user
tracks, then from what the block explorer reported for the contract
(`resolveTokenDecimals()` in `src/shared/approvalAmount.js`). Where none of them
answers, the amount is not formatted: the line reads
`5000000000 base units (decimals unknown)`. A guessed scale is not an
approximation but a different number — 1,000 units of a 6-decimal token
formatted at 18 decimals reads `0.000000001` — on the screen whose only job is
to state what is being authorized. Both amount paths of that screen take this
rule: the ERC-20 `transfer`/`approve` line (`src/popup/views/approval.js`) and
the swap's `Amount` and `Min. received` lines (`src/shared/uniswap.js`). An
unbounded allowance or permit needs no scale to describe and is still shown as
`Unlimited`.

The rule holds only if nothing invents a scale UPSTREAM of it. Those three
sources are read as authoritative, so a value written into one of them cannot be
recognized as a guess afterwards: a fabricated `18` reads exactly like a real
`18`, and the refusal above then never fires. So `fetchTokenBalances()` in
`src/shared/balances.js` stores what the explorer reported or `null`, never a
default, and the same holds for the history list's token transfers in
`src/shared/transactions.js`. A token whose `decimals()` reverts has no scale
anywhere, and a holding of it carries no quantity either: its balance is `null`
— read as unknown, never as zero — and the balance list says so rather than
printing `0.0000` for money that is really there. `0` is a real scale and is
never treated as absent.

`tokenBalances[].decimals` is therefore the explorer's own answer and nothing
else, which is not the same question as the scale a screen should render at.
Anything that needs the second one calls `resolveTokenDecimals()` — the balance
list, the approval and swap lines, and the Send screen, which carries the
resolved scale onto the pending transaction for `transferAmount.js` to encode
and compare against. Reading the stored field directly instead answers `null`
for a bundled or tracked token the explorer merely omitted, which is not a
refusal the wallet has any reason to make.

#### Partial USD totals

Prices are fetched for the top 25 tokens only, so an address can hold assets the
extension has no price for. Worth zero and worth an unknown amount are different
facts and are never collapsed into one number. `getAddressValue()` in
`src/shared/prices.js` returns `{ usd, partial }` — the value of the priced
holdings, and whether an unpriced holding was left out of it — and every screen
renders it through `formatAddressTotal()`, so the wording cannot drift:

- Nothing knowable (testnet, or before the first price fetch): no total line.
- Everything priced: `Total: $5,500.00`.
- Part priced: `Total: $3,000.00 plus unpriced tokens` — the figure is real as
  far as it goes and is kept, named as a floor rather than the total.
- Nothing priced but something held: `Total: unpriced tokens only`. No figure,
  because the only figure available would be the `$0.00` sum of an empty set,
  and on the address-removal confirmation that sits directly under "This address
  holds a balance."

The per-token balance lines are unaffected: each shows its quantity, and a USD
column that is blank for a token with no price.

#### Language & Labeling

All user-facing text avoids unnecessary jargon wherever possible:

- "Recovery phrase" instead of "seed phrase", "mnemonic", or "BIP-39 mnemonic"
- "Address" instead of "account", "derived key", or "HD child"
- "Password" instead of "encryption key" or "vault passphrase"
- Buttons use plain verbs: "Send", "Receive", "Copy address", "Add", "Back",
  "Cancel", "Lock", "Unlock", "Allow", "Deny"
- Helpful inline descriptions where needed (e.g. "This password locks the wallet
  on this device. It is not the same as your recovery phrase.")
- Error messages are full sentences ("Please enter your password." not "password
  required")

#### Full Identifiers Policy

Addresses, transaction hashes, contract addresses, and all other cryptographic
identifiers are displayed in full whenever possible. We truncate only in
specific, limited, non-critical places and even then only a small amount that
still prevents spoofing attacks. Address poisoning attacks exploit truncated
displays by generating fraud addresses that share the same prefix and suffix as
a legitimate address. If a user only sees `0xAbCd...1234`, an attacker can
create an address with the same visible characters and trick the user into
sending funds to it. Showing the complete identifier defeats this class of
attack.

#### Data Model

The core hierarchy is **Wallets → Addresses**:

- A **wallet** is one of three types:
    - An **HD wallet** (`type: "hd"`, recovery phrase): generates multiple
      addresses from a single 12/24 word recovery phrase using BIP-39/BIP-44
      derivation. The user can add more addresses with a "+" button.
    - A **key wallet** (`type: "key"`, private key): a single address imported
      directly from a private key. No "+" button since there is only one
      address.
    - An **xprv wallet** (`type: "xprv"`, extended private key): the same
      multi-address behavior as an HD wallet, including the "+" button and the
      address scan on import, but imported from an extended private key rather
      than a recovery phrase. It therefore has no recovery phrase to display or
      back up. Only a master key may be imported; an xprv wallet already in
      storage that was imported from a non-master key is detected from the depth
      of its stored `xpub` by `src/shared/walletDefects.js`, explained in the
      wallet list, and blocked from signing, sending and private-key export. It
      is never deleted or rewritten.
- An **address** holds ETH and ERC-20 tokens.
- The user can have multiple wallets, each with multiple addresses (HD) or a
  single address (key).

Which tokens an address shows is decided by `fetchTokenBalances()` in
`src/shared/balances.js`, from the Blockscout `token-balances` response, so
tokens do appear without the user adding them. An ERC-20 is shown when its
balance is nonzero and it is in the bundled known-token list, is tracked by the
user, or has 1,000 or more holders; a token claiming a symbol from the bundled
list from any other contract address is always dropped, and so is any token
claiming a symbol that belongs to the native asset and therefore has no
legitimate contract at all (`"ETH"`). That filter is unconditional — the "Hide
tokens with fewer than 1,000 holders" setting governs the transaction history
and the send-screen token selector, not this list. Tracked tokens with a zero
balance are listed as well while "Show tracked tokens with zero balance" is on.

#### Stored state and its version

The whole profile lives under a single extension-storage key, `autistmask`, and
carries a `schemaVersion` — `STATE_SCHEMA_VERSION` in
`src/shared/stateSchema.js`, currently `1`. Every write stamps it: the popup's
`saveState()` and the background's `updateState()` both do, so whichever context
wrote last, the record says which build's shape it is in.

Version 1 is the shape that shipped before versions existed, so a stored record
with no `schemaVersion` is version 1 rather than a defect: it loads normally and
is migrated in place by being stamped on the first write. An upgrade never shows
an existing user a warning about a profile that is perfectly good. The version
is bumped only when the MEANING of a stored field changes — a new field with a
sensible absent value is handled by `normalizePersisted()` and is not a bump,
because bumping for one would send every older install to StateRecovery for
nothing.

Every read of the record goes through `assertStateUsable()` first, on the raw
bytes, before normalization: `loadState()` for the popup and `getState()` for
the background. It refuses a record that is not an object, a `schemaVersion`
this build does not understand (a newer one included), a `wallets` that is not a
list of wallet records with address records in them, and a `networkId` that is
not a network in `src/shared/networks.js`. Refusing is the whole point — a
record the wallet cannot vouch for is never normalized, never written back, and
never half-loaded. The popup shows StateRecovery; a dApp gets a specific error
(`-32007`, an EIP-1474 server-error code the spec leaves unassigned) saying the
saved data cannot be read and that nothing was signed or sent, rather than the
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
`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 `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
recorded. Every key test in the gate is an own-property test for that reason,
and `networkById()` throws on an id it does not know rather than quietly
answering mainnet.

#### Navigation

The main view shows all addresses grouped by wallet, with ETH balances inline.
The user taps an address to see its detail view (full address, balance, tokens,
send/receive). Navigation is a stack: each forward action pushes the current
screen, and every view has a "Back" or "Cancel" button that pops back to it (see
the Screen Map below). There is no hamburger menu and no persistent tab bar; the
Settings gear in the title bar is the only global control. Two screens carry an
in-screen control beyond that: AddWallet uses three tabs to select the import
mode, and AddressDetail keeps its one rarely-used action ("Export Private Key")
behind a "···" menu.

### Screen Map

Navigation uses a stack model (like iOS): each forward action pushes the current
screen onto `state.viewStack`, and "Back" pops it (`pushCurrentView()` and
`goBack()` in `src/popup/views/helpers.js`). The root screen is either Welcome
(no wallets) or Home (has wallets). Each screen below gives its view id in
parentheses; the registry of view ids is the `VIEWS` array in
`src/popup/views/helpers.js`, and the markup for a screen is the element with id
`view-` plus that view id in `src/popup/index.html`.

Three elements sit outside the screens and are present on all of them: the title
bar ("AutistMask by @sneak" plus the Settings gear), the flash message line
under it, and the red banner at the very top that appears on a debug build, when
runtime debug mode is on, or when the active network is a testnet. They are not
repeated in the element lists below. StateRecovery is the one screen they are
not all present on: it hides the Settings gear, because it is shown precisely
when there is no profile for the screens behind that gear to render from.

Closing and reopening the popup returns to the screen the user was last on only
for the views listed in `RESTORABLE_VIEWS` (`src/shared/restorableViews.js`).
Every other screen falls back to Home. The screens that display a secret —
ExportPrivKey and ShowRecoveryPhrase — are deliberately absent from that list,
so the popup can never reopen onto one of them with no password prompt in front
of it. So are the two that destroy one, DeleteWallet and
DeleteWalletLostPassword: a popup reopened by accident must not land on a screen
whose button erases key material.

A reopened popup renders the wallet list and the one screen it restores onto,
and nothing else, so every screen on the stack behind that one is still the
blank template from `index.html`. "Back" therefore renders its target rather
than only unhiding it, through the same dispatch and data guards as the restore
(`src/popup/viewRouter.js`), and falls back to Home when the state the target
would render is gone.

It renders only a screen this page load has not rendered yet. Forward navigation
renders as it goes, and `viewRouter.js` records every screen that reaches
`showView()`, so "Back" onto a screen already on the page unhides it and nothing
more — rendering it a second time would re-fetch and overwrite what it holds,
such as an edit typed into Settings and not yet saved. Home is the one screen
"Back" always re-renders, so the wallet list reflects anything that changed
while the user was away from it.

Every screen that holds secret material in the page registers a cleanup with
`onViewLeave()` (`src/popup/views/helpers.js`), which `showView()` runs on every
exit from that screen rather than only on its "Back" button, so nothing secret
survives in a hidden view once the user has navigated away by any route. That
covers the revealed private key and recovery phrase, the recovery phrase,
private key or extended private key entered on AddWallet, and the password typed
on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign. DeleteWalletLostPassword
registers one as well, for the neighbouring reason rather than that one: a
wallet name is not a secret, but a typed confirmation left standing in a hidden
view would leave a wallet one click from deletion.

#### Welcome (`welcome`)

- **When**: No wallets exist yet (`state.hasWallet` is false). This is the root
  screen in that case.
- **Elements**:
    - "Welcome! To get started, add a wallet." text
    - "Add wallet" button
- **Transitions**:
    - "Add wallet" → **AddWallet**

#### Home (`main`)

- **When**: At least one wallet exists. This is the root screen.
- **Elements**:
    - Active address ETH balance (large) + USD value in parentheses
    - "Total:" USD value across ETH and every token shown for the active
      address, written by `formatAddressTotal()` — see
      [Partial USD totals](#partial-usd-totals)
    - Active address (color dot, full address, etherscan link, tap to copy)
    - Send / Receive quick-action buttons, both acting on the active address
    - ETH/USD price display
    - Wallet list: each wallet shows its name (tap to rename inline) and a "+"
      button for HD and xprv wallets, then one block per address with "Address
      N" (bold when active), the ENS name if resolved, the full address, an
      `[info]` button, an `[x]` button (only on HD and xprv wallets holding more
      than one address), the address USD total, and a balance line for ETH and
      for each token shown for that address
    - "Recent Transactions": up to 25 transactions merged across every address
      of every wallet, deduplicated by hash and filtered
    - "Add additional wallet..." link at bottom
- **Transitions**:
    - Tap address row → sets the active address and broadcasts
      `AUTISTMASK_ACTIVE_CHANGED` (no screen change)
    - Tap wallet name → inline rename field (no screen change)
    - "+" on wallet → derives the next address inline (no screen change)
    - `[info]` on address → **AddressDetail**
    - `[x]` on address → **DeleteAddress**
    - "Send" → **Send** (refuses with a flash message on a zero balance)
    - "Receive" → **Receive** (shows active address QR)
    - Tap home tx row → **TransactionDetail**
    - "Add additional wallet..." → **AddWallet**
    - Settings gear → **Settings** (toggles; tap again to return)

#### AddWallet (`add-wallet`)

- **When**: User wants to add a new wallet (from Welcome, Home, or Settings).
  This one screen covers all three import modes; there is no separate import
  screen.
- **Elements**:
    - "Back" button, "Add Wallet" heading
    - Three tabs — "From Phrase" (`tab-mnemonic`), "From Key" (`tab-privkey`),
      "From xprv" (`tab-xprv`) — each showing its own form section:
        - **From Phrase**: instruction text, a die button that generates a
          random recovery phrase, a recovery phrase textarea, and a backup
          warning box that becomes visible once the die button has been used
        - **From Key**: instruction text and a masked private key input
        - **From xprv**: instruction text and a masked extended private key
          input
    - Password + confirm password inputs, with a hint line whose wording depends
      on the selected tab. Every wording says that the password cannot be
      recovered or reset and names what the only backup of the wallet is — the
      recovery phrase, the private key or the extended private key, according to
      the tab. This is the only warning the user gets before the wallet exists;
      without it, the lost-password route on DeleteWallet is the first they
      would hear of it. The hint line reserves its height, so switching tabs
      cannot move the password fields under the pointer.
    - "Import" button
- **Transitions**:
    - "Import" with a valid entry and a matching password of at least 12
      characters → creates the wallet, clears the navigation stack, and →
      **Home**. The phrase and xprv modes then scan for further used addresses
      and report the count as a flash message.
    - "Import" with an invalid entry, a duplicate wallet or address, or a short
      or mismatched password → flash message, no screen change
    - "Back" → previous screen (Welcome, Home, or Settings)

#### AddressDetail (`address`)

- **When**: User tapped `[info]` on an address from Home.
- **Elements**:
    - "Back" button
    - Blockie identicon (48px, centered)
    - Title: "Wallet Name — Address N"
    - ENS name (if resolved, bold above the address)
    - Full address (color dot, etherscan link, tap to copy)
    - USD total for address (see [Partial USD totals](#partial-usd-totals))
    - Balance list: ETH + the ERC-20 tokens shown for this address (4 decimal
      places, USD inline). Each balance row is clickable → **AddressToken**
    - Send / Receive / + Token buttons and a "···" menu button
    - "···" dropdown containing a single "Export Private Key" entry
    - Transaction list (with ENS resolution for counterparties)
- **Transitions**:
    - Tap balance row → **AddressToken** (for that token)
    - "Send" → **Send** (refuses with a flash message on a zero balance)
    - "Receive" → **Receive**
    - "+ Token" → **AddToken**
    - "···" → "Export Private Key" → **ExportPrivKey**
    - Tap transaction row → **TransactionDetail**
    - "Back" → previous screen (Home)

#### ExportPrivKey (`export-privkey`)

- **When**: User chose "Export Private Key" from the "···" menu on
  AddressDetail. This screen discloses secret material.
- **Elements**:
    - "Back" button
    - Blockie identicon (48px, centered)
    - "Export Private Key" heading
    - "Wallet Name — Address N" and the full address (etherscan link, tap to
      copy)
    - Warning that anyone holding the private key can transfer all funds from
      the address
    - Error line
    - Password input and "Reveal" button, shown until the key is revealed
    - The private key on a highlighted background, tap to copy, shown only after
      the password has been accepted
- **Transitions**:
    - "Reveal" (correct password) → decrypts the wallet secret, derives this
      address's key, hides the password input and shows the key (no screen
      change)
    - "Reveal" (wrong password) → full-sentence error on the error line, nothing
      revealed (no screen change)
    - "Back" → previous screen (AddressDetail)
- **Secret handling**: nothing is decrypted, no key is derived, and nothing is
  written into the page until the password is accepted; the key is never stored
  in state, and it is wiped from the page whenever the screen is left by any
  route, including the Settings gear. A decrypt still running when the screen is
  left is discarded rather than written. The screen is not restorable, so
  reopening the popup lands on Home rather than back on the key.

#### AddressToken (`address-token`)

- **When**: User clicked a specific token balance on AddressDetail.
- **Elements**:
    - "Back" button
    - Blockie identicon (48px, centered)
    - Title: "Wallet Name — Address N — TOKEN"
    - Full address (color dot, etherscan link, tap to copy)
    - USD total for this token
    - Single token balance line (4 decimal places)
    - Send / Receive buttons
    - Token contract well (ERC-20 only): full contract address (tap to copy,
      etherscan link) plus name, symbol, decimals, holder count and project
      website where known
    - Token-filtered transaction list (only this token's transfers)
- **Transitions**:
    - "Send" → **Send** (token locked: the dropdown is replaced by a static
      symbol and contract address)
    - "Receive" → **Receive** (ERC-20 warning shown for non-ETH tokens)
    - Tap transaction row → **TransactionDetail**
    - "Back" → previous screen (AddressDetail)

#### Send (`send`)

- **When**: User wants to send ETH or a token, from Home, AddressDetail, or
  AddressToken.
- **Elements**:
    - "Back" button, "Send" heading
    - From: address with color dot + etherscan link
    - What to send: token dropdown (or static display with contract address when
      locked from AddressToken)
    - To: address or ENS name input, with an inline validation message
    - Amount input with current balance display
    - "Review" button, disabled until the recipient validates
- **Transitions**:
    - "Review" (valid inputs, ENS resolved) → **ConfirmTx**
    - "Review" with an unresolvable ENS name or an invalid amount → flash
      message, no screen change
    - "Back" → previous screen (Home, AddressDetail, or AddressToken)

#### ConfirmTx (`confirm-tx`)

- **When**: User reviewed send details and is ready to authorize.
- **Elements**:
    - "Back" button, "Confirm Transaction" heading
    - Type: "Native ETH transfer" or "ERC-20 token transfer (SYMBOL)"
    - Token contract: full address + etherscan link (ERC-20 only)
    - From: blockie + color dot + full address + etherscan link + wallet title
    - To: blockie + color dot + full address + etherscan link + ENS name
    - Amount: value + symbol (USD in parentheses)
    - Your balance: value + symbol (USD in parentheses)
    - Network fee: "Estimating..." then two lines, or "Unable to estimate",
      fetched async. The first line is what the transfer is expected to cost,
      `gasLimit * gasPrice` (USD in parentheses); the second is the
      `gasLimit * maxFeePerGas` reserve the node requires, which is what the
      balance check gates on. The second line is omitted on a network with no
      type-2 pricing, where the two are the same number, but its space is
      reserved either way
    - Warnings: inline warnings from the local checks (scam address, self-send)
      plus four reserved warning boxes made visible by the async checks —
      recipient with no transaction history, recipient is a contract, burn
      address, and an Etherscan phishing/scam label
    - Errors (insufficient balance), plus three reserved error boxes — the
      amount plus the fee exceeds the balance (ETH transfers), not enough ETH to
      pay the fee for the transfer (ERC-20 transfers), and the fee could not be
      estimated. The first two are mutually exclusive per transfer type, so only
      the applicable one holds space
    - Password: an inline field on this screen, not a modal, with its own error
      line
    - "Sign & Send" button (disabled if errors, and while the network fee
      estimate is pending or unavailable)
- **Transitions**:
    - "Sign & Send" (correct password) → broadcast tx → **WaitTx**
    - "Sign & Send" (correct password) → broadcast fails → **ErrorTx**
    - "Sign & Send" on an ERC-20 whose contract answers `decimals()` with a
      different number than the amount above was displayed with → nothing is
      signed → **ErrorTx** naming both numbers. The transfer is encoded from the
      decimals the screen rendered, carried forward on the pending transaction;
      the contract's own answer is read at signing time only to be compared with
      it, and a disagreement is a refusal rather than a preference for either
      value (`src/shared/transferAmount.js`)
    - "Sign & Send" (wrong password) → "Wrong password." on the password error
      line, no screen change
    - "Back" → **Send**

#### WaitTx (`wait-tx`)

- **When**: Transaction has been broadcast, waiting for on-chain confirmation.
- **Elements**:
    - "Transaction Broadcast" heading (no back button — tx is irreversible)
    - Amount + symbol
    - To: color dot + full address + etherscan link
    - Transaction hash: full hash (tap to copy) + etherscan link
    - Count-up timer: "Waiting for confirmation... Ns"
- **Behavior**: Polls `getTransactionReceipt` every 10 seconds. The wait is
  persisted: closing and reopening the popup resumes the poll, with the elapsed
  counter and the timeout deadline still measured from the original broadcast. A
  lookup that fails is retried on the next tick rather than counted as a missing
  receipt, because a failed lookup says nothing about the transaction; but six
  failures in a row (60 seconds at the poll cadence) end the wait, so an RPC
  that never answers cannot leave it running indefinitely. Any lookup that
  answers resets that count.
- **Transitions**:
    - Receipt found → **SuccessTx**
    - A lookup that answers "no receipt" 60 seconds or more after broadcast →
      **ErrorTx** (timeout message)
    - Six consecutive failed lookups → **ErrorTx**, with a message naming the
      unreachable network and pointing at the RPC URL in Settings. This is a
      different fact from the timeout — the chain was never asked — and says so
    - Exactly one outcome: a receipt found on the tick that crosses the deadline
      wins, and no outcome can be rendered over another

#### SuccessTx (`success-tx`)

- **When**: Transaction confirmed on-chain.
- **Elements**:
    - "Transaction Confirmed" heading
    - Decoded action well (shown when the transaction carried recognized
      calldata; the top-level Amount and To are hidden in that case)
    - Amount + symbol
    - To: color dot + full address + etherscan link
    - Block number
    - Transaction hash: full hash (tap to copy) + etherscan link
    - "Done" button
- **Transitions**:
    - "Done" in the approval popup → closes the popup window
    - "Done" otherwise → resets the navigation stack, then → **AddressToken**
      (if `selectedToken` set) or **AddressDetail**

#### ErrorTx (`error-tx`)

- **When**: Transaction broadcast failed, or timed out waiting for confirmation.
- **Elements**:
    - "Transaction Failed" heading
    - Amount + symbol
    - To: color dot + full address + etherscan link
    - Error message (dashed border box)
    - Transaction hash section (hidden if broadcast failed before getting hash):
      full hash (tap to copy) + etherscan link
    - "Done" button
- **Transitions**:
    - "Done" in the approval popup → closes the popup window
    - "Done" otherwise → resets the navigation stack, then → **AddressToken**
      (if `selectedToken` set) or **AddressDetail**

#### Receive (`receive`)

- **When**: User wants to receive funds at this address, from Home,
  AddressDetail, or AddressToken.
- **Elements**:
    - "Back" button, "Receive" heading
    - Instruction text
    - QR code encoding the address
    - Full address (color dot, selectable, etherscan link)
    - "Copy address" button
    - ERC-20 warning (shown when navigating from AddressToken for non-ETH token)
- **Transitions**:
    - "Back" → previous screen (Home, AddressDetail, or AddressToken)

#### TransactionDetail (`transaction`)

- **When**: User tapped a transaction row on Home, AddressDetail, or
  AddressToken.
- **Elements** (grouped into logical blocks using light well containers; field
  labels are self-explanatory so groups have no headings):
    - "Transaction" heading, "Back" button
    - Transaction hash: full hash (tap to copy) + etherscan link
    - Type: transaction classification — one of: Native ETH Transfer, ERC-20
      Token Transfer, Swap, Token Approval, Contract Call, Contract Creation
    - Status: "Success" or "Failed"
    - From: blockie + color dot + full address (tap to copy) + etherscan link;
      ENS name if available
    - To: blockie + color dot + full address (tap to copy) + etherscan link; ENS
      name if available
    - Time: ISO datetime + relative age in parentheses
    - Block: block number (tap to copy) + etherscan block link
    - Amount: value + symbol (bold)
    - Native quantity: raw integer + unit (shown when available)
    - Token contract: shown for ERC-20 transfers — color dot + full contract
      address (tap to copy) + etherscan token link
    - Decoded details (shown for contract calls): action name, decoded
      parameters, token details, swap steps
    - Network details (shown when on-chain data is available): nonce, gas price,
      gas used, transaction fee (all tap to copy)
    - Raw data (shown when calldata is present): full calldata in monospace
      dashed border
- **Transitions**:
    - "Back" → previous screen (Home, AddressDetail, or AddressToken)

#### AddToken (`add-token`)

- **When**: User wants to track an ERC-20 token, reached from "+ Token" on
  AddressDetail.
- **Elements**:
    - "Back" button, "Add Token" heading
    - Instruction text (find contract address on Etherscan)
    - Contract address input
    - Status line ("Looking up token...", cleared or replaced on failure)
    - Common token quick-pick buttons (top 25 by market cap), which fill the
      contract address input
    - "Add" button
- **Transitions**:
    - "Add" (valid contract) → tracks the token, pops the stack, and re-renders
      **AddressDetail**
    - "Add" with a token already tracked, a scam-listed address, or a failed
      contract lookup → flash message, no screen change
    - "Back" → previous screen (AddressDetail)

#### Settings (`settings`)

- **When**: User tapped the Settings gear.
- **Elements**:
    - "Back" button, "Settings" heading
    - Wallets: one row per wallet with its name (tap to rename inline), a
      `[recovery phrase]` button on HD wallets only, and an `[x]` delete button,
      plus a "+ Add wallet" button
    - Tracked Tokens: one row per tracked token with an `[x]` remove button,
      plus a "+ Add token" button
    - Display: "Show tracked tokens with zero balance" checkbox, "UTC
      Timestamps" checkbox, and a Theme selector (System / Light / Dark)
    - Network: network selector (Ethereum Mainnet / Sepolia Testnet); switching
      resets the RPC and Blockscout endpoints to that network's defaults
    - Ethereum RPC: endpoint URL input + "Save" button (validated against
      `eth_chainId` before being saved)
    - Blockscout API: endpoint URL input + "Save" button (validated against
      `/stats` before being saved)
    - Token Spam Protection:
        - "Hide fake tokens impersonating a known symbol" checkbox
        - "Hide tokens with fewer than 1,000 holders" checkbox
        - "Hide transactions from detected fraud contracts" checkbox
        - "Hide dust transactions below N gwei" checkbox + threshold input. The
          threshold is plain decimal digits, a whole number of gwei, zero or
          greater (zero hides nothing). Anything else — a fraction, a negative,
          a value carrying its unit, hex (`0x10`) or exponent (`1e3`) notation —
          is refused with a flash message and the field snaps back to the stored
          threshold, so a number the user did not type is never stored.
    - Allowed Sites: list with remove buttons
    - Denied Sites: list with remove buttons
    - About: project link, license, author, version, release date, and the
      commit, which links to the commit in the repository
    - Debug: hidden until revealed, then an "Enable debug mode" checkbox that
      turns on the red banner and verbose logging
- **Transitions**:
    - "+ Add wallet" → **AddWallet**
    - "+ Add token" → **SettingsAddToken**
    - `[recovery phrase]` on an HD wallet → **ShowRecoveryPhrase**
    - `[x]` on a wallet → **DeleteWallet**
    - Tap wallet name → inline rename field (no screen change)
    - `[x]` on a tracked token or a site → removes it in place (no screen
      change)
    - Ten clicks on the version → reveals the Debug well (no screen change)
    - "Back" (or Settings gear again) → previous screen (Home)

#### ShowRecoveryPhrase (`show-phrase`)

- **When**: User tapped `[recovery phrase]` on a wallet row in Settings. HD
  wallets only: key and xprv wallets have no recovery phrase, so their rows do
  not offer the action at all.
- **Elements**:
    - "Back" button, "Recovery Phrase" heading
    - Wallet name
    - Warning box stating that anyone holding these words can take everything in
      the wallet, from any device, without the password
    - Error line
    - Password input + "Reveal" button, shown until the password is accepted
    - The recovery phrase itself, in full and click-to-copy, shown only after a
      correct password and in place of the password prompt
- **Transitions**:
    - "Reveal" (correct password) → the phrase replaces the password prompt (no
      screen change)
    - "Reveal" (wrong password) → full-sentence error, nothing revealed (no
      screen change)
    - "Back" → previous screen (Settings)
- **Secret handling**: nothing is decrypted or written into the page until the
  password is accepted; the phrase is never stored in state, and it is wiped
  from the page whenever the screen is left by any route, including the Settings
  gear. A decrypt still running when the screen is left is discarded rather than
  written. The screen is not restorable, so reopening the popup lands on Home
  rather than back on the phrase.

#### DeleteWallet (`delete-wallet-confirm`)

- **When**: User tapped the `[x]` next to a wallet in Settings.
- **Elements**:
    - "Back" button, "Delete Wallet" heading
    - Warning naming the wallet and stating that deletion is permanent and any
      funds are unrecoverable without the recovery phrase
    - Error line
    - Password input
    - "Confirm Delete" button
    - An underlined "I have lost my password" control
- **Transitions**:
    - "Confirm Delete" (correct password, other wallets remain) → deletes the
      wallet and its site permissions, then → **Settings** with a "Wallet
      deleted." flash message
    - "Confirm Delete" (correct password, last wallet) → deletes the wallet,
      clears the selection and the navigation stack, then → **Welcome**
    - Either way, the active address moves only if it belonged to the deleted
      wallet, and `AUTISTMASK_ACTIVE_CHANGED` is broadcast when it does
      (`src/shared/walletDelete.js`)
    - "Confirm Delete" (wrong password) → "That password is incorrect. Please
      try again." on the error line, nothing deleted
    - "I have lost my password" → **DeleteWalletLostPassword**
    - "Back" → previous screen (Settings)

#### DeleteWalletLostPassword (`delete-wallet-lost-password`)

- **When**: User tapped "I have lost my password" on DeleteWallet.
- **Why it exists**: without it, a user who has forgotten the password but still
  holds the recovery phrase has no route back into the product at all. Deletion
  was password-gated, and importing the phrase again is refused as a duplicate
  xpub by `findWalletByXpub()` while the wallet is still stored, so the only
  escape was clearing extension storage through browser internals — which takes
  every other wallet with it.
- **Elements**:
    - "Back" button, "Delete Wallet Without a Password" heading
    - A statement that the password cannot be recovered or reset, so the wallet
      cannot be unlocked again, and that no password is needed to delete it
    - What deletion does and does not do: it erases the copy of the key stored
      on this device; nothing on chain changes and no money is moved
    - The route back — adding the wallet again with the recovery phrase and a
      new password — and, in bold, that without that phrase written down the
      deletion loses everything the wallet holds, forever
    - That the other wallets are not touched
    - The wallet's name, and a text input asking for it to be typed back
    - Error line
    - "Delete This Wallet Forever" button
- **Transitions**:
    - "Delete This Wallet Forever" (name typed correctly) → the same two
      outcomes as "Confirm Delete" above, through the same `finishDelete()`, so
      the selection repair, permission cleanup and `AUTISTMASK_ACTIVE_CHANGED`
      broadcast are identical on both routes
    - "Delete This Wallet Forever" (name does not match) → "That is not the name
      of this wallet. Type &lt;name&gt; to confirm." on the error line, nothing
      deleted
    - "Back" → **DeleteWallet**, re-entered through its `show()` so the wallet
      selection comes back with it. The two delete screens are siblings rather
      than parent and child: nothing is pushed on the way here, so both have
      Settings as their Back target.
- **Deliberately not password-gated.** A password in front of _discarding_ a
  secret protects nobody: an attacker at the popup who wants the wallet gone can
  uninstall the extension, so the only person such a gate stops is the owner who
  forgot it. The typed name is a check that the user knows which wallet they are
  on, not a secret, so it is matched with surrounding spaces and letter case
  ignored.
- Not in `RESTORABLE_VIEWS`, alongside `delete-wallet-confirm`: a popup reopened
  by accident must not land on a screen whose button erases key material.

#### DeleteAddress (`delete-address-confirm`)

- **When**: User tapped the `[x]` next to an address on Home. Offered only on HD
  and xprv wallets holding more than one address: the last address of a wallet
  is never removable, and a key wallet has exactly one.
- **Elements**:
    - "Back" button, "Remove Address" heading
    - The address's own label ("Address N") and its wallet's name
    - The full address (color dot, etherscan link, tap to copy), with the ENS
      name above it if resolved
    - Explanation that this only stops the wallet tracking the address: nothing
      is destroyed, no key is deleted, and funds stay where they are
    - The route back, stated with its limit, because the obvious two are both
      refused: "+" derives the next unused index (`nextIndex` is a high-water
      mark), and re-importing the wallet's key material is rejected as a
      duplicate by `findWalletByXpub` while the wallet is still present. What
      works is deleting the whole wallet in Settings — which destroys the stored
      secret — then importing again, whereupon `scanForAddresses()` rediscovers
      the address **only if it has on-chain activity**. An address that was
      never used is not found by that scan. The text is written by
      `recoveryPathText()` rather than sitting in `index.html`, so it can name
      the wallet's own kind of key material: an xprv wallet has no recovery
      phrase to re-import.
    - A warning when the address holds anything, ETH or any tracked ERC-20,
      followed by the holdings themselves via `balanceLinesForAddress()` and the
      USD total via `formatAddressTotal()` (see
      [Partial USD totals](#partial-usd-totals)). The sentence names no figure
      of its own: the lines round to four decimals, so a sentence built from a
      rounded number would report `0.0000 ETH` for an address holding real
      money. The predicate is `addressHoldsFunds()` in
      `src/popup/views/helpers.js`, unrounded and token-aware. A balance is a
      warning, never a refusal.
    - The rule that a wallet always keeps at least one address, and that
      removing the last one means deleting the wallet from Settings
    - Error line
    - "Remove Address" button
- **Transitions**:
    - "Remove Address" → removes the address and its site permissions, then →
      previous screen (Home) with an "Address removed." flash message
    - "Back" → previous screen (Home), nothing removed
- **Deliberately not password-gated**, unlike DeleteWallet: a password gates the
  disclosure or destruction of a secret, and this does neither. The address
  stays derivable from key material the wallet still holds.
- The active address moves only if it was the address removed, and then to the
  wallet's first remaining address, with `AUTISTMASK_ACTIVE_CHANGED` broadcast
  so a connected site stops being told about an address the user removed
  (`src/shared/walletDelete.js`). A selection in any other wallet is left alone;
  one in this wallet follows the splice.
- The wallet's derivation counter (`nextIndex`) is not rewound, so "+" derives a
  fresh address rather than handing back the one just removed.

#### SettingsAddToken (`settings-addtoken`)

- **When**: User tapped "+ Add token" in Settings. Tokens added here are tracked
  across every address, unlike AddToken which is reached from one address.
- **Elements**:
    - "Back" button, "Add Token" heading
    - Instruction text
    - "Top tokens:" quick-pick buttons (top 10 by market cap; already-tracked
      tokens are disabled)
    - "Or pick from top 100:" dropdown (already-tracked tokens are disabled) +
      "Add selected" button
    - "Or enter contract address:" input, a status line, and an "Add" button
- **Transitions**:
    - Any of the three add paths, on success → adds the token and shows an
      "Added SYMBOL" flash message (no screen change)
    - A duplicate, a scam-listed address, or a failed contract lookup → flash
      message, no screen change
    - "Back" → previous screen (Settings)

#### SiteApproval (`approve-site`)

- **When**: A website requests wallet access via `eth_requestAccounts` or
  `wallet_requestPermissions` and is on neither the allowed nor the denied list.
  The background script prefers the toolbar popup (`action.openPopup()`) and
  falls back to a separate popup window (`src/background/index.js`,
  `requestApproval()`).
- **Elements**:
    - "Connection Request" heading
    - Phishing warning banner (shown when the hostname is on the phishing
      blocklist)
    - Site hostname (bold) + "wants to connect to your wallet"
    - Address that will be shared (color dot + full address + etherscan link)
    - "Remember my choice for this site" checkbox
    - "Allow" / "Deny" buttons
- **Transitions**:
    - "Allow" / "Deny" → closes popup (returns result to background script; the
      choice is persisted to the allowed or denied list when "Remember" is
      checked)
    - Popup closed without answering → treated as a denial

#### TxApproval (`approve-tx`)

- **When**: A connected website requests a transaction via
  `eth_sendTransaction`. Always opened in a separate popup window by the
  background script (`windows.create()`), because the request is triggered
  programmatically rather than by a user gesture. The background populates the
  transaction (nonce, gas limit, fees, chain id) against the RPC node _before_
  opening the window, so the screen shows a complete transaction and the signed
  artifact can be compared with it field for field. A request that cannot be
  populated — unreachable node, reverting gas estimate — opens no window and is
  failed back to the site. Only one transaction approval exists at a time:
  populating fixes the nonce, so a second `eth_sendTransaction` arriving while
  one is unanswered is refused with EIP-1193 code `-32002` rather than being
  populated at the same nonce. It opens no window and takes no nonce, and the
  site can send it again once the pending one is answered.
- **Elements**:
    - "Transaction Request" heading
    - Phishing warning banner (shown when the hostname is on the phishing
      blocklist)
    - Site hostname (bold) + "wants to send a transaction"
    - Decoded action (if calldata is recognized): action name, token details,
      amounts, steps, deadline (see Transaction Decoding)
    - From: color dot + full address + etherscan link
    - Contract: color dot + full address + etherscan link (or "contract
      creation"), token symbol label if known
    - Value: amount in ETH (4 decimal places, USD in parentheses)
    - Network fee (max): gas limit × fee per gas in ETH (4 decimal places, USD
      in parentheses), with the gas limit and the fee per gas in gwei below it
    - Network and nonce
    - Raw data: full calldata displayed inline (shown if present)
    - Password input and an error line
    - "Confirm" / "Reject" buttons
- **Transitions**:
    - "Confirm" (correct password) → decrypts and signs the transaction it was
      shown, exactly as shown, hands the signed transaction to the background to
      broadcast, then → **WaitTx** in the same popup window
    - "Confirm" (wrong password) → error line, no screen change
    - "Reject" → closes popup (returns rejection to background)
    - Popup window closed without answering → the request is rejected with
      EIP-1193 code 4001

#### SignApproval (`approve-sign`)

- **When**: A connected website requests a message signature via
  `personal_sign`, `eth_sign`, or `eth_signTypedData_v4`. Opened the same way as
  TxApproval, in a separate popup window.
- **Elements**:
    - "Signature Request" heading
    - Phishing warning banner (shown when the hostname is on the phishing
      blocklist)
    - Site hostname (bold) + "wants you to sign a message"
    - Danger warning box (shown for `eth_sign`, which signs a raw hash)
    - Type: "Personal message" or "Typed data (EIP-712)"
    - From: color dot + full address + etherscan link
    - Message: decoded UTF-8 text (personal_sign) or formatted domain/type/
      message fields (EIP-712 typed data)
    - Password input and an error line
    - "Sign" / "Reject" buttons
- **Transitions**:
    - "Sign" (correct password) → signs locally → closes popup (returns
      signature)
    - "Sign" (wrong password, or a signing failure) → error line, no screen
      change
    - "Reject" → closes popup (returns rejection to background)
    - Popup window closed without answering → the request is rejected with
      EIP-1193 code 4001

#### StateRecovery (`state-recovery`)

- **When**: `loadState()` refused the stored profile, so the popup has no
  profile at all. It is the only screen reached without one, and the only one
  that never appears during ordinary use.
- **Why it exists**: a record the wallet cannot read used to render nothing — no
  view, no message, no control — while every dApp call answered a generic
  internal error, and no reset or wipe control existed anywhere in the product.
  The only escape was clearing extension storage through browser internals
  ([#311](https://git.eeqj.de/sneak/AutistMask/issues/311)).
- **Elements**:
    - "Saved Data Cannot Be Read" heading, and a statement that nothing has been
      changed or erased and nothing can be signed or sent
    - The problem, in one sentence naming what is wrong with the record
    - "Export Saved Data" button, and the read-only text box it fills
    - What erasing does and does not do, in bold: every wallet stored in this
      browser is deleted; nothing on chain changes and no money is moved
    - A text input asking for `ERASE MY WALLET` to be typed back
    - Error line
    - "Erase Saved Data" button
- **Transitions**:
    - "Export Saved Data" → the raw stored record, verbatim, in the text box on
      the screen, and a downloaded `autistmask-saved-data.json` where the
      browser allows one. The box is filled first and never depends on the
      download: an export that can fail is not an export.
    - "Erase Saved Data" (phrase typed) → the stored record is removed and the
      popup reloads into **Welcome**
    - "Erase Saved Data" (phrase not typed) → "Type ERASE MY WALLET to confirm.
      Nothing was erased." on the error line
- **No other control is reachable.** The Settings gear is hidden while this
  screen is up, because every screen behind it renders from the profile that
  could not be read, and `showView()` is not used to raise it for the same
  reason — it reads and writes the state singleton.
- **Both controls are required.** An export with no reset leaves the user
  looking at a broken profile with no way to use the wallet again; a reset with
  no export destroys the only copy of a record that may hold recoverable key
  material. The typed phrase is the same barrier DeleteWalletLostPassword uses,
  and for the same reason: there is no password to gate this with, since there
  is no profile to check one against.
- Not in `RESTORABLE_VIEWS`: it is never persisted as the current view, because
  nothing on this path writes state at all.

### External Services

AutistMask is not a fully self-contained offline tool. It necessarily
communicates with three external services to function as a wallet:

- **Ethereum JSON-RPC endpoint**: The extension needs an Ethereum node to query
  balances (`eth_getBalance`), read ERC-20 token contracts (`eth_call`),
  estimate gas (`eth_estimateGas`), fetch nonces (`eth_getTransactionCount`),
  broadcast transactions (`eth_sendRawTransaction`), and check transaction
  receipts. The default endpoint is a public RPC (configurable by the user to
  any endpoint they prefer, including a local node). By default the extension
  talks to `https://ethereum-rpc.publicnode.com`.
    - **Data sent**: Ethereum addresses, transaction data, contract call
      parameters. The RPC endpoint can see all on-chain queries and submitted
      transactions.

- **CoinDesk CADLI price API**: Used to fetch ETH/USD and token/USD prices for
  displaying fiat values. The price is cached for 5 minutes to avoid excessive
  requests. No API key required. No user data is sent — only a list of token
  symbols. Note that CoinDesk will receive your client IP.
    - **Data sent**: Token symbol strings only (e.g. "ETH", "USDC"). No
      addresses or user-specific data.

- **Blockscout block-explorer API**: Used to fetch transaction history (normal
  transactions and ERC-20 token transfers), ERC-20 token balances, and token
  holder counts (for spam filtering). The default endpoint is
  `https://eth.blockscout.com/api/v2` (configurable by the user in Settings).
    - **Data sent**: Ethereum addresses. Blockscout receives the user's
      addresses to query their transaction history and token balances. No
      private keys, passwords, or signing operations are sent.

What the extension does NOT do:

- No analytics or telemetry services
- No token list APIs (the known-token list is bundled at build time)
- No Infura/Alchemy dependency (any JSON-RPC endpoint works)
- No backend servers operated by the developer

In addition to the three user-configurable services above (RPC endpoint,
CoinDesk price API, and Blockscout API), AutistMask also contacts:

- **Etherscan address labels**: When confirming a transaction, the extension
  performs a best-effort lookup of the recipient address on Etherscan to check
  for phishing/scam labels. This is a direct page fetch with no API key; the
  user's browser makes the request.

Users who want maximum privacy can point the RPC and Blockscout URLs at their
own self-hosted instances (price fetching can be disabled in a future version).

### Dependencies

AutistMask uses four runtime libraries. All cryptographic operations are
delegated to ethers and libsodium — see the Crypto Policy section below.

| Package                    | Version | License | Purpose                                                                                                                                                                                                                                                 |
| -------------------------- | ------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ethers`                   | 6.16.0  | MIT     | All Ethereum operations: BIP-39 mnemonic generation/validation, BIP-32/BIP-44 HD key derivation (`m/44'/60'/0'/0/n`), secp256k1 signing, transaction construction, ERC-20 contract interaction, JSON-RPC communication, address derivation (keccak256). |
| `libsodium-wrappers-sumo`  | 0.8.2   | ISC     | Password-based encryption of secrets at rest: Argon2id key derivation (`crypto_pwhash`), authenticated encryption (`crypto_secretbox` / XSalsa20-Poly1305).                                                                                             |
| `qrcode`                   | 1.5.4   | MIT     | QR code generation for the Receive screen (renders address as scannable QR on canvas).                                                                                                                                                                  |
| `ethereum-blockies-base64` | 1.0.2   | ISC     | Deterministic pixelated identicon generation from Ethereum addresses (same style used by Etherscan).                                                                                                                                                    |

Dev dependencies (not shipped in extension):

| Package            | Version | License | Purpose                   |
| ------------------ | ------- | ------- | ------------------------- |
| `esbuild`          | 0.27.3  | MIT     | JS bundler (inlines deps) |
| `tailwindcss`      | 4.2.1   | MIT     | CSS compilation           |
| `@tailwindcss/cli` | 4.2.1   | MIT     | Tailwind CLI              |
| `jest`             | 30.2.0  | MIT     | Test runner               |
| `prettier`         | 3.8.1   | MIT     | Code formatter            |

### Crypto Policy

**No raw crypto primitives in application code.** If the strings `aes`, `sha`,
`pbkdf`, `hmac`, `encrypt`, `decrypt`, `hash`, `cipher`, `digest`, `sign`
(case-insensitive) appear in our own source code (outside of `node_modules/`),
it is almost certainly a bug. All cryptographic operations must go through
`ethers` or `libsodium-wrappers-sumo`. Both are widely audited and
battle-tested.

Exceptions require explicit authorization in a code comment referencing this
policy, but as of now there are none.

### Content Security Policy

Both manifests declare the same policy for extension pages, as an object under
`content_security_policy.extension_pages` in `manifest/chrome.json` (MV3) and as
a bare string in `manifest/firefox.json` (MV2):

```
default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self';
style-src 'self' 'unsafe-inline'; img-src 'self' data:;
connect-src 'self' https: http:; frame-src 'none'; form-action 'none';
base-uri 'none'
```

`default-src 'self'` is the floor. Without it the policy governed script and
plugins only, and everything else — frames above all — was unrestricted, which
is what let an unescaped token symbol paint a cross-origin iframe over the
wallet's own UI. Escaping is the primary fix for that (see
`src/shared/html.js`); this is the second line, so an escape that does slip
cannot reach the network.

Four directives are looser than `'self'`, each for a reason that does not
generalise:

- `style-src 'unsafe-inline'` — `src/popup/index.html` and the view helpers set
  presentation through `style="..."` attributes, which CSP blocks without this.
  Chrome enforces `style-src` on attributes, not only on `<style>` blocks, and
  Firefox has never implemented `style-src-attr`, so there is no narrower
  spelling that works on both targets. It permits inline **style**; script stays
  under `script-src`, which does not allow `'unsafe-inline'`.
- `img-src data:` — identicons are generated in the popup by
  `ethereum-blockies-base64` and assigned to `img.src` as `data:` PNGs.
- `connect-src https: http:` — the RPC endpoint is user-configurable and a local
  node over `http://127.0.0.1` is a supported configuration, which the Firefox
  end-to-end suite depends on. The wallet's outbound traffic is constrained by
  what it is written to contact (see External Communication), not by this
  directive.
- `frame-src 'none'`, `form-action 'none'`, `base-uri 'none'` — named rather
  than inherited. `form-action` and `base-uri` do not fall back to `default-src`
  at all, so they would have stayed unrestricted; `frame-src 'none'` is what
  refuses the framed-overlay attack outright.

`'wasm-unsafe-eval'` is there for one reason: libsodium. It ships a WebAssembly
build and a `wasm2js` translation of it in one file, tries WASM first, and
silently falls back to the translation if instantiation throws. Under a plain
`script-src 'self'` the fallback was taken on every popup load, announced by
nothing but an uncaught `CompileError`. Measured on the same Argon2id parameters
the vault uses (`OPSLIMIT_INTERACTIVE`, `MEMLIMIT_INTERACTIVE`), WASM derives a
key in 141-198ms and `wasm2js` in 3204-3660ms. The work factor is identical — it
is set by the ops and memory parameters, not by wall time — so the fallback
bought nothing and cost about three and a half seconds on every operation that
asks for the password, which is every signature.

The keyword permits compiling WebAssembly and nothing else: not `eval()` of
strings, not inline script, not remote script. Using it requires already
executing script in an extension page, which is complete compromise on its own.
`'unsafe-eval'` is a different proposition and is not granted.

The policy is pinned in both directions. `tests/manifest.test.js` asserts the
exact directive set and the exact token set of each directive in both manifests,
so dropping `'wasm-unsafe-eval'` (a silent 20x regression on the key
derivation), dropping `default-src`, and adding anything anywhere all fail
`make check`. `tests/vaultBackend.test.js` asserts the unit tests run the WASM
backend, and `make test-e2e` compiles a WebAssembly module inside the real popup
under the real manifest.

### DEBUG Mode Policy

The `DEBUG` constant in the popup JS enables a red "DEBUG / INSECURE" banner and
a hardcoded test mnemonic. **DEBUG mode must behave as close to normal mode as
possible.** No `if (DEBUG)` branches that skip functionality, bypass security
flows, or alter program behavior beyond the banner and the hardcoded mnemonic.
Adding new DEBUG-conditional branches requires explicit approval from the
project owner.

`DEBUG` is a build-time constant, not a runtime setting. `build.js` injects it
into the bundle as the `__BUILD_DEBUG__` define — `false` unless the build was
run with `AUTISTMASK_DEBUG=1` (see [Debug Builds](#debug-builds)) — and
`src/shared/constants.js` reads it. It cannot be changed after the bundle is
produced.

The debug-mode toggle in settings is a separate, runtime-only flag. It raises
the log level and turns the banner on, and that is all it may ever do: it feeds
`isDebug()` in `src/shared/log.js`, which is deliberately not what
`generateMnemonic()` consults. Mnemonic generation reads the build-time `DEBUG`
constant directly, so no runtime toggle in a release build can reach the
hardcoded test phrase.

### Key Decisions

- **No framework**: The popup UI is vanilla JS and HTML. The extension is small
  enough that a framework adds unnecessary complexity and attack surface.
- **Split storage model**: Public data (xpubs, derived addresses, token lists,
  balances) is stored unencrypted in extension local storage so the user can
  view their wallets and balances at any time without entering a password.
  Private data (recovery phrases, private keys) will be encrypted at rest using
  libsodium — a password is only required when the user needs to sign a
  transaction or message. The encryption scheme for private data:
    - The user's password is run through Argon2id (`crypto_pwhash`) to derive a
      256-bit encryption key. Argon2id is memory-hard, making GPU/ASIC brute
      force attacks expensive.
    - The derived key encrypts the secret material using XSalsa20-Poly1305
      (`crypto_secretbox`), which provides authenticated encryption (the
      ciphertext cannot be tampered with without detection).
    - Stored blob: `{ salt, nonce, ciphertext }` (the auth tag is part of the
      `crypto_secretbox` output).
    - **The password is NOT used in address derivation.** It exists solely to
      protect the recovery phrase / private key on disk. Anyone with the
      recovery phrase can restore the wallet on any device without this
      password. This matches standard EVM wallet behavior.
- **BIP-39 / BIP-44 via ethers.js**: Mnemonic generation, validation, and HD key
  derivation (`m/44'/60'/0'/0/n`) are handled entirely by ethers.js. The BIP-39
  passphrase is always empty (matching most wallet software). The user's
  password is completely separate and has no effect on which addresses are
  generated.
- **ethers.js for everything Ethereum**: Transaction construction, signing, gas
  estimation, RPC communication, ERC-20 contract calls, and address derivation
  are all handled by ethers.js. This means zero hand-rolled Ethereum logic.
- **EIP-1193 provider**: The content script injects a `window.ethereum` object
  that implements the EIP-1193 provider interface, enabling web3 site
  connectivity.
- **Minimal RPC**: The extension communicates with Ethereum nodes via JSON-RPC
  through ethers.js's `JsonRpcProvider`. The default endpoint is configurable.
  No Infura dependency — users can point it at any Ethereum JSON-RPC endpoint.

### Supported Functionality

- Create new HD wallet (generates 12-word recovery phrase)
- Import HD wallet from existing 12 or 24 word recovery phrase
- Import single-address wallet from private key
- Import multi-address wallet from an extended private key (`xprv`)
- Add multiple addresses within an HD wallet
- Manage multiple wallets simultaneously
- View ETH balance per address
- View ERC-20 token balances (tokens on the bundled known-token list, tokens
  with 1,000 or more holders, and tokens the user adds by contract address)
- Send ETH to an address
- Send ERC-20 tokens to an address
- Receive ETH/tokens (display address, copy to clipboard, QR code)
- Connect to web3 sites (EIP-1193 `eth_requestAccounts`)
- Sign transactions requested by connected sites (`eth_sendTransaction`)
- Sign messages (`personal_sign`, `eth_sign`)
- Sign typed data (`eth_signTypedData_v4`, `eth_signTypedData`)
- Human-readable transaction decoding (ERC-20, Uniswap Universal Router)
- ETH/USD and token/USD price display
- Configurable RPC endpoint and Blockscout API
- Address poisoning protection (spam token filtering, dust filtering, fraud
  contract blocklist)

### Address Poisoning and Fake Token Transfer Attacks

During development, one of our test addresses
(`0x66133E8ea0f5D1d612D2502a968757D1048c214a`) sent 0.005 ETH to
`0xC3c693Ae04BaD5f13C45885C1e85a9557798f37E`. Within seconds, a fraudulent
transaction appeared in the address's token transfer history
(`0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84`) showing a
0.005 "ETH" transfer from our address to
`0xC3C0AEA127c575B9FFD03BF11C6a878e8979c37F` — a scam address whose first four
characters (`0xC3C0`) visually resemble the legitimate recipient (`0xC3c6`).

**How it works:** A scammer deploys a malicious ERC-20 contract (in this case,
`0xD05339f9Ea5ab9d9F03B9d57F671d2abD1F55c82`, a fake token calling itself
"Ethereum" with symbol "ETH" and zero holders). This contract has a function
that emits an ERC-20 `Transfer(from, to, amount)` event with arbitrary
parameters. The EVM does not enforce that the `from` address in a Transfer event
actually initiated or authorized the transfer — any contract can emit any event
with any parameters. The scammer calls their contract, which emits a Transfer
event claiming the victim sent tokens to the scam address. Every blockchain
indexer (Blockscout, Etherscan, etc.) sees a valid Transfer event log and
indexes it as a real token transfer.

**The attack has two goals:**

1. **Autocomplete poisoning**: Wallets that offer address autocomplete based on
   transaction history will suggest the scam address (which looks similar to a
   legitimate recent recipient) when the user starts typing. The user copies the
   wrong address and sends real funds to the scammer.

2. **Transaction history confusion**: The fake transfer appears in the victim's
   history as an outbound transaction, making it look like the user sent funds
   to the scam address. Users who copy-paste addresses from their own
   transaction history may grab the wrong one.

**What AutistMask does about it:**

- **Minimal, careful truncation**: Where space constraints require truncation
  (e.g. the transaction history list), AutistMask truncates conservatively —
  displaying enough characters that generating a vanity address matching the
  visible portion is computationally infeasible. All confirmation screens
  (transaction signing, send confirmation) display the complete untruncated
  address. Users should always verify the full address on the confirmation
  screen before signing or sending.

- **Known token symbol verification**: AutistMask ships a hardcoded list of
  high-market-cap ERC-20 tokens with their legitimate contract addresses and
  symbols. The list is a point-in-time snapshot of the highest-market-cap
  Ethereum mainnet ERC-20s taken from the CoinGecko API, with decimals verified
  on-chain and addresses EIP-55 checksummed; `TOKENS` in
  `src/shared/tokenList.js` is the authoritative set. It is bundled at build
  time and only changes when that file is regenerated. Any token transfer
  claiming a symbol from this list (e.g. "ETH", "USDT", "USDC") but originating
  from an unrecognized contract address is identified as a spoof and filtered
  from display. The fake "Ethereum" token in the attack above used symbol "ETH"
  from contract `0xD05339f9Ea5ab9d9F03B9d57F671d2abD1F55c82`, which does not
  match the known WETH contract — so it would be caught by this check. Detecting
  a spoof is also what adds a contract to the fraud contract blocklist below;
  that is the only thing that populates it. In the transaction history the check
  is the "Hide fake tokens impersonating a known symbol" setting, on by default;
  with it off, spoofed transfers are shown and no new blocklist entries are
  learned from them. The send-screen token selector and the balance list apply
  the same check unconditionally, because they decide which tokens the user can
  act on and what the user believes they own rather than what the history
  displays. All three surfaces read the rule from `src/shared/symbolSpoof.js`,
  so they cannot answer the question differently. A symbol the list maps to no
  contract at all — `"ETH"`, the native asset, is the only one — may be borne by
  no contract, so every ERC-20 claiming it is a spoof on all three. The user's
  real ETH balance is not an ERC-20 and is read over RPC, so the rule never sees
  it.

- **Low-holder token filtering**: Token transfers from ERC-20 contracts with
  fewer than 1,000 holders are hidden from transaction history by default.
  Legitimate tokens have substantial holder counts; poisoning tokens typically
  have zero. This catches new poisoning contracts that use novel symbols not in
  the known token list.

- **Fraud contract blocklist**: AutistMask maintains a local list of known fraud
  contract addresses. Token transfers involving these contracts are filtered
  from the transaction history display. The list is populated when a fraudulent
  transfer is detected and persists across sessions.

- **Send-side token filtering**: Tokens with fewer than 1,000 holders are
  excluded from the token selector on the send screen. This prevents users from
  accidentally interacting with a spoofed token that appeared in their balance
  via a fake Transfer event.

- **Dust transaction filtering**: A second wave of the same attack used real
  native ETH transfers instead of fake tokens. Transaction
  `0x2708ebddfb9b5fa3f7a89d3ea398ef9fd8771b83ed861ecb7c21cd55d18edc74` sent 1
  gwei (0.000000001 ETH) from `0xC3c6B3b4402bD78A9582aB6b00E747769344F37E` —
  another look-alike of the legitimate recipient `0xC3c693...`. Because this is
  a real ETH transfer (not a fake token), none of the token-level filters catch
  it. AutistMask hides transactions below a configurable dust threshold
  (default: 100,000 gwei / 0.0001 ETH). This is high enough to filter poisoning
  dust while low enough to preserve any transfer a user would plausibly care
  about. The threshold is user-configurable in Settings; a threshold of `0`
  hides nothing, exactly as clearing the checkbox does.

- **User-configurable**: All four filters (known symbol verification, low-holder
  threshold, fraud contract blocklist, dust threshold) are settings that default
  to on but can be individually disabled by the user. AutistMask is designed as
  a sharp tool — users who understand the risks can configure the wallet to show
  everything unfiltered, unix-style. All four settings govern the transaction
  history; what else each one reaches varies. The known-symbol check also runs
  unconditionally on the send-screen token selector and on the balance list, in
  both cases identically to the history. The fraud contract blocklist is applied
  unconditionally on that selector and is not consulted by the balance list at
  all. The low-holder setting also gates the send selector, while the balance
  list's own 1,000-holder floor is unconditional (see Data Model). The dust
  threshold applies to the transaction history alone.

#### Phishing Domain Protection

AutistMask protects users from known phishing sites when they connect their
wallet or approve transactions/signatures. A community-maintained domain
blocklist is vendored into the extension at build time and checked entirely
locally: no network request is made for it, ever, so nobody learns which sites
the user connects to and no third party decides what this wallet warns about.

The trade is freshness. The shipped list is exactly as current as the last
vendoring run that was released, so a domain added upstream reaches users in the
next release rather than within a day. Refreshing it is `make vendor-blocklist`,
which fetches a hash-pinned upstream commit, verifies the sha256 of the bytes it
was served, and rewrites `src/shared/phishingBlocklist.json`; the diff is
committed and ships with the next version.

The artifact holds digests, not domain names: sha256 truncated to 64 bits, one
entry per 16 hex characters, concatenated in sorted order into a single string
(`src/shared/domainHash.js`). A lookup hashes the hostname and its parent
domains and binary-searches that string, so nothing is built at module load —
which matters on MV3, where the worker re-evaluates the module on every wake —
and the file is 1.7 MB rather than 8.7 MB. Storing digests is also what makes a
list assembled elsewhere shippable here at all: the extension carries no
plaintext list of anyone's domain names.

When a dApp on a blocklisted domain requests a wallet connection, transaction
approval, or signature, the approval popup displays a prominent red warning
banner alerting the user. The domain checker matches exact hostnames and all
parent domains (subdomain matching).

#### Transaction Decoding

When a dApp asks the user to approve a transaction, AutistMask attempts to
decode the calldata into a human-readable summary. This is purely a display
convenience to help the user understand what they are signing — it is not
endorsement, special treatment, or partnership with any protocol.

AutistMask is a generic web3 wallet. It treats all dApps, protocols, and
contracts equally. No contract gets special handling, priority, or integration
beyond what is needed to show the user a legible confirmation screen. Our
commitment is to the user, not to any service, site, or contract.

Decoded transaction summaries are best-effort. If decoding fails, the raw
calldata is displayed in full. The decoders live in self-contained modules under
`src/shared/` (e.g. `uniswap.js`) so they can be added for common contracts
without polluting wallet-specific code. Contributions of decoders for other
widely-used contracts are welcome.

Currently supported:

- **ERC-20**: `approve()` and `transfer()` calls — shows token symbol, spender
  or recipient, and amount.
- **Uniswap Universal Router**: `execute()` calls — shows swap direction (e.g.
  "Swap USDT → ETH"), token addresses, amounts, execution steps, and deadline.
  Decodes Permit2, V2/V3/V4 swaps, wrap/unwrap, and balance checks.

### Non-Goals Forever

- Built in token swaps (use a DEX in the browser)
- Analytics, telemetry, or tracking of any kind
- Advertisements or promotions
- Obscure token list auto-discovery — nothing outside the bundled list, the
  1,000-holder floor, and the tokens the user added by contract address
    - We detect common/popular ERC20s in the basic case
- Fiat on/off ramps
- Extensive transaction decoding/parsing
    - For common ones we will do best-effort, but you should just use a block
      explorer.

### Non-Goals for 1.0

- Multi-chain support (Ethereum mainnet only)
- Hardware wallet support

## TODO

### Wallet Management

- [x] Delete wallet (with confirmation)
- [x] Delete address from HD wallet (with confirmation)
- [x] Show wallet's recovery phrase (requires password)

### Transactions

- [x] Gas estimation and fee display before confirming

### Testing

- [x] Tests for mnemonic generation and address derivation
- [x] Tests for xpub derivation and child address generation
- [ ] Test on Firefox (Manifest V2)

### Scam List

- [ ] Research and document each address in scamlist.js
- [ ] Add more known fraud addresses from Etherscan labels

### Future

- [ ] Multi-currency fiat display (EUR, GBP, etc.)
- [ ] Security audit of key management

## Policies

- We don't mention "the other wallet" by name in code or documentation. We're
  our own thing.
- The README is the complete authoritative technical documentation. It's ok if
  it gets big.

## License

GPL-3.0. See [LICENSE](LICENSE).

### Third-Party Data Files

This repository includes data files from third-party projects that are not
covered by the GPL-3.0 license above. These files, their copyright holders, and
their licenses are:

| File                                                       | Source                                                                                                   | Copyright                         | License                                                        |
| ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | --------------------------------- | -------------------------------------------------------------- |
| `src/shared/phishingBlocklist.json`                        | `eth-phishing-detect` community-maintained phishing domain blocklist, derived from its `src/config.json` | Copyright (c) 2018 kumavis        | [DBAD (Don't Be a Dick)](https://github.com/philsturgeon/dbad) |
| `src/shared/scamlist.js` (address data from MyEtherWallet) | [ethereum-lists](https://github.com/MyEtherWallet/ethereum-lists) `addresses-darklist.json`              | Copyright (c) 2020 MyEtherWallet  | MIT                                                            |
| `src/shared/scamlist.js` (address data from EtherScamDB)   | [EtherScamDB](https://github.com/MrLuit/EtherScamDB) `scams.yaml`                                        | Copyright (c) 2018 Luit Hollander | MIT                                                            |

The full license texts for these third-party files are included in the
[LICENSE](LICENSE) file. The `eth-phishing-detect` row carries no repository
link because the upstream is hosted under a competitor's organization name,
which project policy keeps out of code and documentation.
`script/vendor-blocklist` is the single definition and the only file that spells
the name in prose: it is build-time tooling, never shipped, and it records the
exact URL, the commit it is pinned to and the sha256 of the bytes that commit
serves, because a source reference nobody can verify is not a source reference.
`script/check-censored` reads the name back out of that one file and fails the
build wherever else it appears, save for three shipped-code literals it cannot
avoid — each permitted only at the one path that carries it, and listed in that
script's header.

## Author

[@sneak](https://sneak.berlin)
