# 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 fetches a community-maintained phishing domain blocklist
periodically and performs best-effort Etherscan address label lookups during
transaction confirmation.

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

### Debug Builds

`make build` always produces a release build: the build-time `DEBUG` constant is
`false`, so wallet creation uses real entropy and the red banner is off. To
produce a debug build instead, set `AUTISTMASK_DEBUG=1` in the environment:

```bash
make build-debug          # or: AUTISTMASK_DEBUG=1 make build
```

Only the exact value `1` enables it; any other value (including unset, empty, or
`true`) yields a release build, so a typo cannot accidentally ship the debug
behavior. The build prints which mode it used. 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 builds 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.

## 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 required;
  see [End-to-End Tests](#end-to-end-tests))
- `script/test-e2e-firefox` — run the Firefox browser end-to-end suite (docker
  required; builds its own pinned image, see
  [End-to-End Tests](#end-to-end-tests))
- `script/lint` — run the linter
- `script/fmt` — format all files (writes)
- `script/fmt-check` — check formatting (read-only)
- `script/check` — run test, test-verify-build, lint, and fmt-check
- `script/verify-build` — assert the compiled `DEBUG` state of the bundles in
  `dist/`: every bundle containing `src/shared/constants.js` must have `DEBUG`
  off, or on when `AUTISTMASK_DEBUG=1`. Run automatically at the end of
  `make build` and `make build-debug`; fails loudly rather than passing if it
  cannot determine a bundle's state. Not part of `make check`, which does not
  depend on build artifacts existing.
- `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. 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/`
- `make build-debug` — the same build with `AUTISTMASK_DEBUG=1` (see
  [Debug Builds](#debug-builds))
- `make clean` — remove `dist/`
- `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 `script/test-e2e`;
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 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).

Three limits of that coverage, none 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. And the EIP-1193 error code does not survive the last hop: the
rejection that crosses the boundary carries code 4001 and is asserted to, but
`src/content/inpage.js` rebuilds it as `new Error(message)`, so the calling page
catches an error with no `code` property.

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's **own** startup request — the phishing blocklist fetch that
`src/background/index.js` issues on startup, which on the suite's throwaway
profile always happens because no previous fetch timestamp is persisted — to
arrive in the route handler, and aborts the entire suite if none does within 30
seconds (`tests/e2e/harness.js`). The check is passive on purpose: a synthetic
probe fetched from inside the worker via `worker.evaluate()` was tried first and
rejected, because evaluating in an extension service worker that early kills the
worker outright, destroying the thing being measured. Observing traffic the
extension already generates perturbs nothing. Losing the race 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:
a `ReferenceError` from a used-but-not-imported identifier is invisible to
`make check` (`script/lint` is only `prettier --check`) but fatal in a browser,
and this suite exists because exactly that class of bug shipped twice.

### 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, and the Add Token screen.
The suite lives in `tests/e2e/firefox/` and has **no npm dependencies at all**:
it is a small WebDriver client built on global `fetch` and `child_process`
against geckodriver's HTTP API.

Unlike the Chrome suite it builds its own container image rather than pulling a
published one, 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-script errors should arrive by the same route, but this suite
does not exercise it and does not claim it — with `--network none` there is no
`http://` page for a content script to be injected into. Errors from add-on
install and background startup are folded into step 1 rather than discarded.
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.
- **Nothing is stubbed, which inverts the coverage of network-dependent code.**
  There is no fixture layer; the container runs with `--network none` instead,
  so the run is offline and deterministic and no request can escape. The
  extension swallows its own fetch failures, so the flows are unaffected — but
  every network call fails, so only the _failure_ branches of code that depends
  on one are ever executed. A `ReferenceError` in the success path of
  `renderTransactions`, or of price or balance rendering, passes this suite
  green. The offline run is also weaker than the Chrome suite's interception: it
  proves nothing got out, but it cannot report which requests were attempted.
  Closing that gap needs a fixture layer, deliberately out of scope for this
  harness.

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. Neither is wired into the Gitea workflow yet —
docker-in-docker in CI is a separate question. Run them locally before changing
anything under `src/popup/views/`.

## 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. Both recurring jobs — the 60-second balance refresh and the 24-hour
  phishing blocklist refresh — are 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, including the timestamp of the
  last phishing list fetch: without it a revived worker would either re-fetch on
  every wake or, with a naive in-memory guard, never notice that an update is
  due. `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.

Both jobs also carry a freshness guard, and a guard must never be timed to the
alarm period it gates. Each 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 two jobs solve this differently, because their guards exist for
different reasons:

- The phishing refresh has a 24-hour cache TTL whose job is to keep the worker
  off the network on the wakes between scheduled refreshes — Chrome revives the
  worker every ~30 seconds while the browser is busy, and every revival runs the
  startup path. The scheduled alarm tick is not one of those wakes, so it
  bypasses the TTL and fetches unconditionally. Shortening the TTL instead would
  not work: the startup path re-checks it on every wake, so a shorter TTL simply
  becomes the real refresh rate.
- 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. That has to
  keep applying on the scheduled tick, so the guard is shortened to half the
  alarm period instead of 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.

Two timestamps are persisted for the phishing list, not one. `lastFetchTime`
records a fetch that produced a usable delta and drives the TTL.
`lastAttemptTime` records that the network was contacted at all, and is written
even when the result is unusable — a failed request, or a delta over the 256 KiB
cap. Without it those cases leave no freshness mark and the worker re-downloads
the full blocklist on every wake, indefinitely; with it, unscheduled retries are
floored at one hour. Both are discarded on load if they are in the future, since
a stamp from a skewed clock or a restored backup would otherwise suppress
updates until that time arrives, permanently and with no way out.

The startup path (`ensureRecurringAlarms()` plus the phishing list init) 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.

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.

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

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

Closing and reopening the popup returns to the screen the user was last on only
for the views listed in `RESTORABLE_VIEWS` (`src/popup/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.

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.

#### 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
    - 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
    - "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
    - 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" (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
- **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) → "Wrong password." on the error line,
      nothing deleted
    - "Back" → previous screen (Settings)

#### 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 — password-gated, and it
      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 `getAddressValueUsd()`. 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.
- **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

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

- **Phishing domain blocklist**: A community-maintained phishing domain
  blocklist is vendored into the extension at build time. At runtime, the
  extension fetches the live list once every 24 hours to detect newly added
  domains, plus once on a start where the list is more than 24 hours old. Only
  the delta (domains not already in the vendored list) is kept in memory,
  keeping runtime memory usage small. The delta and the timestamp of the fetch
  that produced it are persisted to extension storage if the record is under 256
  KiB; an oversized delta is dropped along with its timestamp, so a later start
  fetches again rather than claiming freshness for data it no longer holds. A
  fetch that fails, or one whose delta was too large to store, is not retried
  more than once an hour outside the 24-hour schedule.
- **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 —
`script-src 'self' 'wasm-unsafe-eval'; object-src 'self'` — as an object under
`content_security_policy.extension_pages` in `manifest/chrome.json` (MV3) and as
a bare string in `manifest/firefox.json` (MV2).

`'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 grant is pinned in both directions. `tests/manifest.test.js` asserts the
exact token set in both manifests, so dropping `'wasm-unsafe-eval'` (a silent
20x regression on the key derivation) and adding anything beyond it both 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, providing immediate
protection without any network requests. At runtime, the extension fetches the
live list once every 24 hours and keeps only the delta (newly added domains not
in the vendored list) in memory. This architecture keeps runtime memory usage
small while ensuring fresh coverage of new phishing domains.

The 24-hour cadence is an alarm, not a timer; the alarm tick fetches
unconditionally rather than re-checking the 24-hour cache TTL that gates the
startup path; and the fetch timestamps live in extension storage rather than in
module variables — see [Background scheduling](#background-scheduling) for why
all three are required.

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, vendored 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; the vendored copy and
the runtime refresh both come from that upstream, whose URL is the
`BLOCKLIST_URL` constant in `src/shared/phishingDomains.js`.

## Author

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