# Workflow

- `git pull` `next` and cut a branch from it — one branch per issue, named
  `issue-<N>-<slug>`. Never branch from `main`.
- Do the work as one commit whose title ends with ` (closes #N)`, with the
  `TODO.md` update in that same commit.
- Move Next Step to the top of Completed Steps; move the top item of Future
  Steps into Next Step.
- Run `make fmt`, then `make check`. A feature branch may be red; `next` and
  `main` may not.
- Rebase onto current `next` immediately before pushing — other branches land on
  `next` continuously — and re-run `make check` after resolving, because a clean
  textual merge can still break the build.
- Push the branch and open one PR per issue with base `next`. Never base `main`.
- An independent reviewer who did not write the change gates the merge. On a
  passed review the PR is squash-merged into `next`.
- `next` is the branch for the next milestone. It is kept green and mergeable to
  `main` at any moment, without notice.
- `main` receives exactly one PR per milestone, from `next`. Releases are tagged
  from `main`.

# Status

pre-1.0, working towards the 1.0.0 milestone. Tagged v0.1.0 on 2026-02-27. The
milestone is in flight on `next`; its `next` -> `main` PR is
[#190](https://git.eeqj.de/sneak/AutistMask/pulls/190). `make check` verified
green on `next` at `e9fa8be` on 2026-08-10, and `make build` produces
`dist/chrome/` and `dist/firefox/`, verified against the build's own receipt to
hold exactly the regular files and symlinks that build emitted, with `DEBUG`
compiled off.

The backlog lives on the
[Gitea tracker](https://git.eeqj.de/sneak/AutistMask/issues), which is
authoritative; this file does not duplicate it. Full policy file set present.
Real-browser end-to-end suites (`make test-e2e` for Chrome,
`make test-e2e-firefox` for Firefox) sit alongside `make check`, which now does
static analysis as well as formatting, and `.gitea/workflows/e2e.yml` runs both
of them on every push.

# Next Step

Pre-1.0 security review of the extension (key handling, DEBUG mode policy, RPC
input validation) before any 1.0rc tag. Individual filed issues are parts of it,
but the review is broader than any of them.

# Completed Steps

- 2026-08-23: A swap amount and the token it is counted in now always come from
  the same hop, on both sides of the approval screen
  ([#359](https://git.eeqj.de/sneak/AutistMask/issues/359) and
  [#364](https://git.eeqj.de/sneak/AutistMask/issues/364), the output and input
  halves of one gate, fixed as one unit). `src/shared/uniswap.js` gated the
  token and the amount on truthiness and independently; an address is never
  falsy once set but an amount of `0n` is, so a hop supplying a zero amount
  fixed the token and left the amount open, and the next hop's figure was then
  rendered against the first hop's token at that token's scale — 0.5 WETH shown
  as `500000000000.0000 USDT`, and an earlier hop's `Min. received` shown for a
  final leg that guarantees nothing. Both sides are now set as a pair through
  explicit presence, a zero slippage floor reads `None (no minimum guaranteed)`,
  and V4's `OPEN_DELTA` (an `amountIn` of zero, which `V4Router` reads as "swap
  the whole open credit") reads `All available (V4 open delta)` instead of
  `0.0000`.
- 2026-08-23: A swap whose input token the calldata never named is said to be
  unknown instead of being called ETH
  ([#357](https://git.eeqj.de/sneak/AutistMask/issues/357)), the twin on the
  input side of [#353](https://git.eeqj.de/sneak/AutistMask/issues/353). A null
  `inputToken` rendered as `Token In: ETH (native)` and titled the swap
  `Swap ETH -> X`, asserting the user was paying native ETH when nothing in the
  calldata said so. The null-means-ETH collapse is now gone from `tokenInfo()`
  itself rather than guarded at each call site: null is refused, and native ETH
  keeps arriving as the explicit zero address that `WRAP_ETH` and V4's
  `Currency.wrap(address(0))` both use.
- 2026-08-23: A swap whose output token the calldata never named is said to be
  unknown instead of being called ETH
  ([#353](https://git.eeqj.de/sneak/AutistMask/issues/353)). `tokenInfo(null)`
  answers `{symbol: "ETH", decimals: 18}`, and a V4 step could take the
  `Min. received` figure while naming no output currency, so the approval screen
  stated the wrong asset at the wrong scale. Null is not how V4 spells native
  ETH: v4-core's `type Currency is address` wraps `address(0)` for it, which
  reaches the decoder as the truthy string
  `0x0000000000000000000000000000000000000000` and is named ETH there already.
  `Token Out` now reads `Unknown (not named in the calldata)` and
  `Min. received` falls to the base-unit refusal from
  [#340](https://git.eeqj.de/sneak/AutistMask/issues/340).

- 2026-08-23: The stored profile carries a schema version, and a record the
  wallet cannot read produces a screen instead of a blank popup
  ([#311](https://git.eeqj.de/sneak/AutistMask/issues/311)). `saveState()` and
  `updateState()` both stamp `STATE_SCHEMA_VERSION`
  (`src/shared/stateSchema.js`), and every read goes through
  `assertStateUsable()` on the raw bytes before normalization gets a chance to
  paper over them. Version 1 is the shape that shipped unversioned, so the
  profile every existing install holds loads normally and is migrated in place
  by being stamped on the first write — an upgrade shows nobody a wipe prompt
  for a wallet that is fine. A record this build cannot vouch for is refused
  instead: not normalized, not written back, not half-loaded. The popup shows
  the new StateRecovery screen, which names the problem, exports the raw record
  verbatim into the page (and downloads it where the browser allows), and offers
  an erase behind a typed `ERASE MY WALLET` — both controls, because an export
  with no reset leaves the user stuck and a reset with no export destroys the
  only copy of possibly recoverable key material. The background refuses the
  same record and answers dApps `-32007` — a code EIP-1474 leaves unassigned,
  unlike `-32000`..`-32006` — with a message saying the saved data cannot be
  read and that nothing was signed or sent, rather than the generic `-32603`
  that every request used to get. Fields the gate deliberately does not check
  produced the same blank popup on their own: `trackedTokens` and
  `activeAddress` were floored on truthiness rather than on type, and
  `trackedTokens`' ENTRIES and each address's `tokenBalances` were not floored
  at all — `[1, 2]` is a list, and the dereference is `t.address.toLowerCase()`
  one level below the container. All of them are type-checked now, entries
  included, and the header of `src/shared/stateSchema.js` lists which fields of
  the record get a type check and which get a `saved.x || default` or a verbatim
  passthrough, rather than asserting a rule the module does not follow.
  `networkById()` now throws on an id it does not know instead of quietly
  answering mainnet, and the gate's key tests are all own-property tests:
  `networkId` is an object key into `networkEndpoints`, so an unvalidated
  `"__proto__"` used to set that map's prototype and drop the user's endpoint
  silently. The three corrupt blobs from the issue drive the real popup entry
  point in `tests/stateRecovery.test.js` and the real worker in
  `tests/stateUnusableRpc.test.js`; each rendered nothing at all and answered
  `-32603` before this. `src/popup/restorableViews.js` moved to
  `src/shared/restorableViews.js`, since `persistedState.js` requires it and
  that module is in the background bundle.

- 2026-08-23: An explorer that reports no `decimals` for a token no longer has a
  scale invented for it before storage
  ([#349](https://git.eeqj.de/sneak/AutistMask/issues/349)).
  `fetchTokenBalances()` did `parseInt(item.token.decimals || "18", 10)` on the
  way in, so a token whose `decimals()` reverts was written to
  `tokenBalances[].decimals` as a fabricated `18` that no reader could tell from
  a real one. That is upstream of the resolve-or-refuse rule
  ([#306](https://git.eeqj.de/sneak/AutistMask/issues/306),
  [#340](https://git.eeqj.de/sneak/AutistMask/issues/340)): both approval paths
  read this stored value as an authoritative source, so the guess walked past
  refusals that were intact and simply never fired. The stored value is now the
  explorer's own answer or `null`, and both the ERC-20 amount line and the swap
  lines reach `unknownDecimalsAmount()` on it. The history list's token
  transfers carried the same `|| "18"` and now state base units with the scale
  unknown rather than a quantity. A holding whose scale nothing knows carries
  `balance: null` — unknown, not zero — and the balance list, the USD total, the
  Send screen and the confirmation screen each say so instead of printing
  `0.0000` for money that is really there. The uint8 check is one shared
  `toDecimals()` rather than three copies, and it answers `0` for a real scale
  of zero: `|| "18"` collapsed that to eighteen, the trap of
  [#246](https://git.eeqj.de/sneak/AutistMask/issues/246). Existing installs
  hold `18`s that cannot be told apart retroactively; they display exactly as
  they do today until the next balance refresh, which rewrites `tokenBalances`
  wholesale and needs no user action. No `|| 18` or `?? 18` fallback remains
  anywhere in `src/`; the literal `18`s that do remain are real data, not
  defaults — 432 per-token `decimals: 18` entries in the bundled
  `src/shared/tokenList.js`, and, outside that file, only native ETH's
  protocol-defined scale in `src/shared/uniswap.js` and the fixed-point
  comparison scale in `src/shared/txValidation.js`. `tokenBalances[].decimals`
  is the explorer's answer alone and not the scale a screen renders at, so the
  Send screen resolves through `resolveTokenDecimals()` like every other
  consumer: reading the stored field raw carried a `null` into `estimateGas()`
  for a bundled token such as WETH, which reported an unestimable network fee
  and left Send disabled behind a message no retry could clear. Send resolves
  with `wallets`, which adds the cross-address disagreement check the balance
  list does not make, so the two can differ; where they do, the stored quantity
  was computed at a scale Send has refused, and it is withdrawn with it. An
  unknown scale is an unknown balance, and the user is told that rather than
  that the fee could not be estimated.

- 2026-08-23: The background no longer reads or writes the shared `state`
  singleton ([#324](https://git.eeqj.de/sneak/AutistMask/issues/324)), which
  also closes the cold-worker wrong-chain send
  ([#320](https://git.eeqj.de/sneak/AutistMask/issues/320)). One in-memory copy
  loaded once is the popup's lifetime, not the MV3 worker's: the worker is
  killed when idle, nothing loaded state at module scope, and an unpopulated
  read was answered out of `DEFAULT_STATE` in silence. Five defects traced to
  that, and every point fix added a `loadState()` that created the next one — a
  load detaches the objects an in-flight handler is holding. The background now
  has its own storage layer (`src/background/state.js`): `getState()` for a
  detached per-call read, `updateState()` for a queued read-modify-write.
  `backgroundRefresh()` refreshes a private copy and applies the balances that
  came back by address, so a wallet added, renamed or deleted during the round
  trip survives. The transaction attempt takes its chain id and its endpoint
  from one snapshot, so a committed chain switch can no longer move the endpoint
  under an artifact already verified against the old chain. `getProvider()` now
  REQUIRES the network id, which is what closes
  [#320](https://git.eeqj.de/sneak/AutistMask/issues/320) at the shape rather
  than at the call site. The prohibition is enforced by `build.js`, which fails
  the build when esbuild's own metafile reports `src/shared/state.js` as an
  input of either background bundle — the resolution the shipped bundle was
  actually built from, so no specifier syntax and no resolution rule can slip
  past it, and `make build` runs in CI. A bundled entry point under
  `src/background/` with no line in the table fails the build too, so a second
  worker entry point is protected by default rather than only if whoever adds it
  knows the table exists. The assertion itself is pinned by
  `tests/buildForbiddenInputs.test.js`, including every way its table can rot: a
  key no bundled entry point matched, a forbidden module this build bundled
  nowhere, and an entry that lists no modules (which would otherwise empty the
  lint rule's forbidden set as well, and is refused at require time). Its bound
  is that it is keyed by path, so a COPY of the singleton at another path is
  outside it — loud for three of the five defects and silent for the other two;
  the bounds are recorded in full where the table lives
  (`script/lib/forbiddenBundleInputs.js`). An ESLint rule that walks the require
  graph textually gives the same answer in the editor, before a full bundle; it
  reads the same table, and it is fast feedback rather than the guarantee. The
  shapes it catches are pinned by `tests/backgroundStateLintRule.test.js`, and
  so are the two it misses — a computed specifier and a symlink — as asserted
  non-reports, which the build fails on. Reading an unloaded singleton now
  throws `StateNotLoadedError` instead of serving defaults. The
  `chrome.storage.local` stubs in eight test files aliased instead of
  structured-cloning, which could let an assertion pass on a build that never
  wrote anything; every test that drives real persistence now goes through
  `tests/support/storageStub.js`.
- 2026-08-23: A swap always names its output token
  ([#346](https://git.eeqj.de/sneak/AutistMask/issues/346)). The `Token Out`
  detail line in `src/shared/uniswap.js` was pushed only when a symbol was
  known, so a swap whose output token is absent from the bundled list — every
  newly listed token — showed a `Min. received` figure with nothing saying what
  was being received. The line is now keyed on the token's address and falls
  back to it when there is no symbol, exactly as the `Token In` line already
  did. It composes with the unknown-scale refusal from
  [#340](https://git.eeqj.de/sneak/AutistMask/issues/340): the address says
  which token, the base-unit figure says how much and states that the scale is
  unknown.
- 2026-08-23: The swap approval screen no longer guesses 18 decimals for a token
  outside the bundled list
  ([#340](https://git.eeqj.de/sneak/AutistMask/issues/340)). `tokenInfo()` in
  `src/shared/uniswap.js` returned `decimals: 18` for any such token — the same
  assumption [#306](https://git.eeqj.de/sneak/AutistMask/issues/306) removed
  from the ERC-20 amount line — so a 1,000-unit swap of a 6-decimal token was
  stated as `0.000000001`, and every newly listed token reached it. The swap's
  `Amount` and `Min. received` lines now resolve the scale through
  `resolveTokenDecimals()`, the same bundled-list-then-tracked-then-explorer
  order the ERC-20 line uses, and where nothing knows it they render
  `unknownDecimalsAmount()` — base units with the scale stated — instead of a
  number. No new data source and no network call: the scale comes only from what
  the wallet already holds. An unbounded permit is still shown as `Unlimited`.
  `README.md` records the rule as a Display Consistency exception.
- 2026-08-23: A failed release build no longer leaves a loadable debug bundle in
  `dist/` ([#333](https://git.eeqj.de/sneak/AutistMask/issues/333)). With
  `AUTISTMASK_DEBUG=1` exported, `make build` compiled a debug bundle and failed
  on it in `script/verify-build` — but the bundle stayed on disk, loadable, with
  every wallet it creates using the publicly committed test recovery phrase.
  Every step of `make build` now runs through `script/discard-dist-on-failure`,
  which removes `dist/` when a step fails and says on stderr that it did and
  why; a removal it cannot complete is reported just as loudly.
  `make build-debug` is deliberately not wrapped: its output is not mistakable
  for a release build and is the evidence of the failure.
  `script/test-verify-build` asserts the state of `dist/` on disk after a
  failing and a succeeding step, not just the exit status, and reads `make -n`
  to check the wrapper is on the release path and only there.
- 2026-08-23: `README.md` and `script/verify-build`'s own comments now state the
  emitted-tree guarantee at the width the code actually enforces
  ([#331](https://git.eeqj.de/sneak/AutistMask/issues/331)). The tree walk is
  `-type f -o -type l`, so the guarantee covers regular files and symlinks under
  `dist/`; fifos, sockets, device nodes and empty directories are not checked,
  because a build emits none of them, none can carry a shippable payload, and
  `grep` on a fifo would hang rather than fail. The exclusion is deliberate and
  unchanged — the README said "nothing under `dist/` that the build did not
  write", which was broader than that. Documentation only; no executable line
  changed.
- 2026-08-23: An amount below the 4-decimal display floor no longer reads as
  zero on the approval screens
  ([#322](https://git.eeqj.de/sneak/AutistMask/issues/322)). With the token's
  true scale resolved, the 4-decimal truncation still printed a small amount as
  `0.0000` — 1 base unit of an 18-decimal token, 500 of an 8-decimal one — so a
  real transfer, allowance or swap was stated as nothing on the one screen whose
  job is to say what is being authorized, and a swap's `Min. received` claimed
  the user might receive nothing. Three copies of that truncation existed; they
  now share `src/shared/amountDisplay.js`. Everything the approval and
  confirmation screens render (`src/popup/views/approval.js`,
  `src/shared/uniswap.js`) extends to the first significant digit when the
  truncated figure would otherwise read as zero, keeping the amount in token
  units rather than switching to base units mid-line. The history and balance
  lists (`src/shared/transactions.js`) keep the unfloored rule, which is out of
  scope by the issue's definition of done. `README.md`'s Display Consistency
  section records the exception.
- 2026-08-23: The extension can be installed once and kept
  ([#310](https://git.eeqj.de/sneak/AutistMask/issues/310)). There was no
  packaging target anywhere, no artifact, and `manifest/chrome.json` carried no
  `key` — so an unpacked Chrome load derived its extension id, and therefore its
  `chrome.storage.local` partition, from the absolute checkout path: moving or
  re-cloning the checkout presented an empty wallet with no error. The manifest
  now carries a fixed `key` (public half only; the private half is a credential
  and is not in this repo, and no target generates one into the tree), pinning
  the id to `gipbhkogfopeahplcjhipkgpcimdpkip`. `make package` produces
  `release/autistmask-chrome-<version>.zip` and
  `release/autistmask-firefox-<version>.xpi` plus `SHA256SUMS`,
  deterministically and via `make build` so the archives can only be made from a
  `dist/` already verified against that build's receipt as a release build;
  every path the manifests and the popup HTML reference is resolved and required
  to be inside the archive, and the archive is read back off disk and compared
  member by member — `dist/styles.css` sits at the `dist/` root outside both
  browser directories and is reported as deliberately not shipped rather than
  dropped by a glob. One version: `script/lib/version.js` fails the build when
  `package.json` and the two manifests disagree, instead of reading from one of
  them. `BUILD_COMMIT` now carries `-dirty` when the working tree does not match
  `HEAD` (and `-unknown` when git cannot say), while the full hash behind the
  About screen's commit link stays clean so the link still resolves. Two real
  browser observations back it: `tests/e2e/storagePartition.js` loads the
  extension from two different paths in one Chrome profile with and without
  `key` and records what each does, and `tests/e2e/firefox/reinstall.js`
  installs the packaged XPI in a real Firefox, creates a wallet, restarts the
  browser on the same profile, adds the add-on again and decrypts the vault back
  to the original recovery phrase — and then observes that an explicit uninstall
  DESTROYS that storage, which is correct browser behaviour but means Remove is
  irreversible for a wallet, now stated in README.md. Deliberately not done: AMO
  signing, CRX packing, tagging and any upload — the Firefox artifact is
  UNSIGNED and README.md now states that release Firefox and ESR refuse it, that
  Developer Edition or an Unbranded build is required, and that a temporary
  add-on does not survive a browser restart.
- 2026-08-20: A second extension page can no longer silently delete a wallet
  ([#304](https://git.eeqj.de/sneak/AutistMask/issues/304)). `saveState()` wrote
  the entire state blob, and every extension page — the toolbar popup, a dApp
  approval window, `backgroundRefresh()` — holds its own in-memory `state`,
  loaded once, with `showView()` saving on every navigation; a second page that
  saved after a first had written something new overwrote it, no attacker or
  unusual input required. `saveState()` is now a read-modify-write: it re-reads
  storage, diffs the persisted fields against a deep-cloned `baseline` snapshot
  taken at the last `loadState()`/`saveState()` on that page, and writes only
  the fields that actually changed — everything else is carried forward from
  storage in its loaded-and-normalized shape (`normalizePersisted()`, shared
  with `loadState()`), so a legacy or malformed record a load has always
  self-healed in memory keeps getting written back even on a save that touched
  something else entirely. `showView()` fires `saveState()` on every navigation
  without awaiting it, so two saves from the same page can be in flight at once;
  a FIFO queue serializes them rather than letting a slow one finish after a
  later one and re-derive a stale answer. Deliberately not done: the live
  `state` of a field this page does not own is not rehydrated from what another
  page wrote, only the persisted record is — adopting a concurrently-written
  value into `state` reintroduced the same clobber one page later, caught by
  `tests/txStatus.test.js` red. Two writers of the same field still resolve
  last-writer-wins, documented at the merge point. `tests/stateMerge.test.js`
  covers the two-page save and the approval-window reproduction from the issue —
  add a wallet in one page, force a save from a second page loaded before it,
  both wallets survive — each demonstrated failing against the unfixed full-blob
  write.
- 2026-08-20: A forgotten password no longer wedges the wallet
  ([#312](https://git.eeqj.de/sneak/AutistMask/issues/312)). Deleting a wallet
  was password-gated and importing its recovery phrase again was refused as a
  duplicate xpub, so a user who had the phrase but not the password could
  neither leave nor come back: the only way out was clearing extension storage
  through browser internals, which takes every other wallet with it.
  DeleteWallet now offers "I have lost my password", a screen that destroys the
  wallet after the user types its name back — no password, because requiring one
  to _discard_ a secret protects nobody. An attacker at the popup who wants the
  wallet gone can uninstall the extension; the only person such a gate stopped
  was the owner who forgot it. That was chosen over allowing a duplicate xpub to
  re-encrypt in place: re-import would have had to be built three times over
  (`hd` and `xprv` by xpub, `key` by address), would make the user retype the
  recovery phrase into a live popup to change a password, and reaches no state
  that delete-then-import does not already reach through `scanForAddresses()`.
  Both routes share one `finishDelete()`, so the selection repair, the
  site-permission cleanup and the `AUTISTMASK_ACTIVE_CHANGED` broadcast cannot
  diverge between them, and the new screen is excluded from `RESTORABLE_VIEWS` —
  a popup reopened by accident must not land on a button that erases key
  material. AddWallet's password hint now says, per import mode, that the
  password cannot be recovered or reset and what the only backup is; the hint
  line reserves its height so switching tabs cannot move the password fields.
  The test drives the real view against a `chrome.storage.local` stub that
  structured-clones on both `set` and `get` and asserts against the read-back,
  so it fails on the deletion of `saveState()` and not only on an in-memory
  splice.
- 2026-08-20: `make build` can no longer hand back a debug build, and
  `script/verify-build` can no longer be satisfied by bytes the build did not
  produce ([#309](https://git.eeqj.de/sneak/AutistMask/issues/309)). The
  verifier used to compute its expectation from `AUTISTMASK_DEBUG` in its own
  environment, so an operator with that exported who ran the release target got
  a debug bundle — every wallet it creates carrying the publicly committed test
  phrase — certified green at exit 0. The expected mode is now the required
  argument `--expect release|debug`, with no default and nothing read from the
  environment, and the `Makefile` scrubs the flag from the verifier while
  deliberately leaving it reaching the compiler, so that shell fails the build
  loudly instead of quietly getting something other than what it asked for.
  Provenance was the other half: the check was a marker grep over a file list
  read back out of `dist/`, so a 26-byte file containing only
  `autistmask-build-debug=off` verified `ok`, `manifest.json` and the content
  script that runs on every page were never read at all, and an entire
  hand-written `dist/` passed. `build.js` now records every file it emits, with
  its sha256 and whether it is one of the bundles containing `constants.js`,
  into a receipt whose path the `Makefile` makes fresh per invocation outside
  the repo and deletes afterwards; `dist/constants-bundles.txt` is gone, and
  `dist/` is cleared before a build so it holds only what that build wrote. The
  standalone `make verify-build` target went with it: re-verifying a `dist/`
  from the `dist/` itself is the thing that was broken. What this establishes is
  narrow and stated as such in README.md — `dist/` is byte for byte the output
  of the `build.js` run that just finished — and it is not signing, which is
  [#310](https://git.eeqj.de/sneak/AutistMask/issues/310).
  `script/test-verify-build` grew from 18 cases to 39, including one per
  demonstrated bypass and the `make -n` read-back that proves the recipes pass
  the mode as an argument.
- 2026-08-20: A hostile ERC-20 symbol no longer renders as live HTML in the
  popup ([#307](https://git.eeqj.de/sneak/AutistMask/issues/307)). A token
  symbol is whatever the contract's `symbol()` returns, the block explorer
  passes it through unfiltered, and `balanceLine()` interpolated it into an
  `innerHTML` string — so a token with the 1,000 holders the spam filter asks
  for, airdropped to the victim, could paint a full-viewport cross-origin iframe
  over the wallet's own UI, on the screens where the user types their password.
  `escapeHtml` moved to `src/shared/html.js` as a pure string replace over `&`,
  `<`, `>`, `"` and `'`: the old implementation round-tripped through a detached
  element's `textContent`, which does not escape quotes, and it was already
  being used inside `data-copy="..."`. Every interpolation into an `innerHTML`
  string across `src/popup/views/` was audited, not just the reported one — the
  transaction lists' direction label, the wallet name and ENS name in the Home
  list, the `href` in the explorer link, and the confirmation screen's warning
  line were all unescaped as well. Both manifests now declare
  `default-src 'self'` with `frame-src 'none'`; the four directives that had to
  stay looser than `'self'` are named and justified in the Content Security
  Policy section of README.md, and `tests/manifest.test.js` pins the whole set
  exactly. A display cap of 12 characters bounds the symbol, matching the bound
  `lookupTokenInfo()` already applied on the contract-read path. Not repurposed
  for any of this: `isSpoofedSymbol()`, which answers a different question and
  would have been the wrong control.
- 2026-08-20: A page asking which chain the wallet is on is told the chain the
  user is actually on ([#317](https://git.eeqj.de/sneak/AutistMask/issues/317)).
  `eth_chainId` and `net_version` answered from `currentNetwork()`, which reads
  the module-level `state` singleton that nothing populates at module scope, so
  a service worker revived by the page's own message answered out of
  `DEFAULT_STATE` and reported mainnet `0x1`/`1` to a user on Sepolia — a dApp
  building its interaction for the wrong chain. Both now answer from
  `getState()`, the per-call detached storage read the other read handlers use,
  rather than from the singleton: these two are reachable by any page on every
  provider init, and mutating the shared singleton on that path would detach the
  wallet objects an in-flight `backgroundRefresh()` is mutating. The read side
  of the background was audited with it: the remaining singleton reads are the
  chain switch, the transaction verification path and `backgroundRefresh`, which
  each already load, and everything else answers from storage per call through
  `getState()`. One stale read is left named but unfixed, outside this issue's
  scope: `handleSendTransaction` builds its provider with no network name, so
  `getProvider()` falls back to the same unloaded singleton for ethers' static
  network hint.
- 2026-08-20: The dApp approval screen no longer shows a token transfer it
  cannot scale as `0.0000`
  ([#306](https://git.eeqj.de/sneak/AutistMask/issues/306)). `decodeCalldata`
  read decimals from the 512-entry bundled token list alone and fell back to 18,
  so every token outside it — most of them, including anything the user added by
  contract address — was displayed at the wrong scale: a `transfer` of 5,000
  units of a 6-decimal token read as `0.0000`, and a user who reads zero
  confirms the drain. The new `src/shared/approvalAmount.js` resolves the scale
  from the bundled list, then `state.trackedTokens`, then the decimals the block
  explorer already reported in `addr.tokenBalances`, and refuses one the
  explorer's own entries disagree about. Where no source knows it, the amount
  line is not formatted at all: it shows the base-unit integer and states that
  the scale is unknown, for `approve` as well as `transfer`. An unbounded
  allowance still reads `Unlimited`, which needs no scale.
- 2026-08-20: A web page can no longer switch the wallet's chain, and switching
  no longer destroys the user's endpoints
  ([#308](https://git.eeqj.de/sneak/AutistMask/issues/308)).
  `wallet_switchEthereumChain` was answered for any origin at all, with no
  connection check and no prompt: any page could clear the `[TESTNET]` banner
  under a user who believed they were on Sepolia. It now takes the same
  `allowedSites`/`connectedSites` gate the signing methods take, ahead of the
  same-chain and unsupported-chain answers, and refuses an unconnected origin
  with `4100`. The switch itself also overwrote `state.rpcUrl` and
  `state.blockscoutUrl` with the network defaults, so a user running their own
  node lost that url permanently and silently to a public endpoint that then
  sees every address they hold. Endpoints are now remembered per network in
  `state.networkEndpoints`, snapshotted from the network being left and restored
  for the network being entered; `state.rpcUrl` stays the live value for the
  active network, so no reader changed. A profile written before the map existed
  has its stored pair adopted for the network it was stored under, and loses
  nothing. The handler now loads state before it switches
  ([#316](https://git.eeqj.de/sneak/AutistMask/issues/316)): the service worker
  populates nothing at module scope, so a worker revived by the page's own
  message held `DEFAULT_STATE`, and the switch persisted every field of it —
  wiping every wallet, every site approval and every tracked token from storage
  along with the endpoint.
- 2026-08-20: The wallet's own ERC-20 send signs the amount it displayed
  ([#305](https://git.eeqj.de/sneak/AutistMask/issues/305)). The confirmation
  screen renders from the block explorer's cached decimals; the transfer was
  encoded from `decimals()` read off the contract at signing time, and nothing
  compared the two, so a token whose on-chain scale disagreed — an upgradeable
  or proxy token, a stale explorer entry, a compromised Blockscout — signed an
  amount that was never on screen, off by a power of ten per decimal place of
  disagreement. The scale is now carried forward on the pending transaction from
  the same balance entry the screen's amount, balance and symbol come from, and
  the contract's answer is read at signing time only to be compared with it: a
  disagreement is a refusal naming both numbers, never a preference for either
  (`src/shared/transferAmount.js`, the `confirmTx` counterpart to
  `approvalVerify.js`). The gas estimate encodes from the same carried value and
  no longer reads `decimals()` at all. Nothing in the e2e suite had ever clicked
  `#btn-confirm-send`, which is how this shipped: the popup's own Send →
  ConfirmTx → Sign & Send → WaitTx path now runs end to end to a broadcast, with
  the `transfer()` amount decoded out of the raw signed bytes and asserted
  against what the screen displayed, and a companion case where the contract
  starts answering a different scale after the screen was built and nothing
  reaches the RPC. Reverting only the signing-side comparison turns that second
  case red and leaves the other 53 green.
- 2026-08-17: The Settings screen is driven in a browser, and every element id
  the popup looks up is checked statically. Nothing exercised Settings in the
  e2e suite, and jest runs with no DOM, so the densest run of `$("...")` lookups
  in the codebase was unverified at runtime. Seven new cases in
  `tests/e2e/run.js` reach Settings, assert the About well and the wallet list
  were actually written, assert the four Token Spam Protection checkboxes are
  real checkboxes defaulted on, and assert the theme and network selectors offer
  the choices `src/shared/networks.js` and `index.html` define. The selectors
  are then driven to `dark` and `sepolia` — neither is the first `<option>`, so
  neither can be read back from the markup with no JavaScript involved — and
  reasserted after a popup reopen before being restored the same way, and one
  spam filter is toggled off and back on across a reopen each way. Those round
  trips run the change handler, `saveState()`, `loadState()` and the
  `init()`/`show()` assignments rather than just looking at the screen. `show()`
  no longer guards its `settings-network` lookup with `if (networkSelect)`: a
  missing element must fail loudly, which is the whole failure mode this unit
  exists to catch. Each group records a coverage key and a final case demands
  the exact set, so a shortened or skipped section reddens the run instead of
  shrinking it. `tests/popupElementIds.test.js` is the general half and runs in
  `make check` with no browser: every literal id reached through `$()`,
  `document.getElementById()`, `showError()`/`hideError()` and `showView()` must
  exist in `src/popup/index.html`, which no id in `index.html` may define twice.
  Demonstrated on four deliberate breaks — a typo'd id (both halves red), a
  handler bound to the wrong but existing element (only the functional e2e case
  red), a typo in a view no browser suite opens (only the static guard red), and
  the deletion of both persisted-value assignments in `settings.js` (only the
  selector round-trip case red)
  ([#229](https://git.eeqj.de/sneak/AutistMask/issues/229)).
- 2026-08-17: The phishing blocklist is vendored at build time and censored, and
  the runtime fetch is gone
  ([#219](https://git.eeqj.de/sneak/AutistMask/issues/219)).
  `script/vendor-blocklist` fetches upstream at a pinned commit, verifies the
  sha256 of the bytes it was served, and writes
  `src/shared/phishingBlocklist.json` as truncated sha256 digests rather than
  domain names — which is what removes the competitor's name from a list that
  carried it 6,475 times, without dropping a single one of those domains.
  `script/check-censored` runs in `make check` and again against `dist/` at the
  end of every build, each permitted occurrence scoped to the one path allowed
  to carry it; the name now appears only in the vendoring script, which defines
  it once, in the provider-shim identifiers in `src/content/inpage.js`, and in
  one ERC-20's on-chain name in `src/shared/tokenList.js`. Removing the fetch
  retired the delta, the persistence and the 24-hour alarm from
  [#158](https://git.eeqj.de/sneak/AutistMask/issues/158), and retired alarms
  are now cleared rather than left running on existing installs. Two
  consequences, both deliberate: the list no longer self-updates, so it is as
  fresh as the last vendoring run that was released; and re-vendoring from
  current upstream took it from 231,357 stale entries to 105,721 current ones,
  because upstream prunes and the vendored snapshot never did. `dist/` fell from
  18.9 MB to 8.9 MB. The e2e suite now drives the warning end to end from a real
  blocklisted origin, and its service-worker interception canary has a new
  anchor, because the startup fetch it used to watch for no longer exists.
- 2026-08-17: One wording for an empty password field on every screen that asks
  for one. The private key export screen said "Password is required." where the
  other five say "Please enter your password.", the same one-condition-two-
  wordings split that [#172](https://git.eeqj.de/sneak/AutistMask/issues/172)
  closed for a rejected password. Strings only, no behaviour change.
  `tests/passwordMessages.test.js` now pins the empty-field guard per call site
  as well as the decrypt handler, anchored on the `decryptWithPassword` sites so
  the wallet-creation screen — where an empty field means a password being
  chosen, a different condition — stays out of the set. Every error container
  measured at a 360px viewport in the pinned Playwright container: the export
  screen's container holds at 20px with the following section at the same offset
  for the old string, the new string and the empty reserved state
  ([#265](https://git.eeqj.de/sneak/AutistMask/issues/265)).
- 2026-08-17: One shared extension-API module,
  [`src/shared/browserApi.js`](src/shared/browserApi.js), is the only place in
  the tree that names `browser` or `chrome`. Every call site returns a promise;
  `runtime.lastError` is gone. The same commit gives the Firefox suite the four
  dApp round trips — `eth_requestAccounts`, `personal_sign`,
  `eth_sendTransaction` and a closed approval window rejecting with EIP-1193
  4001 — against a page and a JSON-RPC node served from loopback, which survives
  `--network none`. **The premise of
  [#153](https://git.eeqj.de/sneak/AutistMask/issues/153) does not survive that
  harness**: Firefox's `browser.*` honours a trailing Chrome-style callback and
  populates `runtime.lastError`, both measured directly on Firefox 153.0.3, and
  all four flows pass against the unconverted code. What landed is a uniformity
  and coverage change, not a repair of a broken target. `storageGet()` and
  `storageSet()` **reject** where `storage.local` is absent rather than
  resolving `{}` and a no-op write — they carry the wallet, and defaulting would
  read an existing wallet back as none. The one caller that genuinely degraded,
  [`src/shared/phishingDomains.js`](src/shared/phishingDomains.js), took
  `storageLocal()` directly and kept its own null check; it stores nothing at
  all as of [#219](https://git.eeqj.de/sneak/AutistMask/issues/219) above.
- 2026-08-17: An address total no longer reports `$0.00` for holdings it cannot
  price. Prices exist for the top 25 tokens only, so the priced-only sum was
  printed as the total and an address holding nothing but unpriced ERC-20s was
  shown as worth nothing — directly under "This address holds a balance." on the
  address-removal confirmation. `getAddressValue()` in `src/shared/prices.js`
  now returns `{ usd, partial }`, keeping worth-zero and worth-an-unknown-amount
  apart the way an absent `holders_count` is kept apart from a count of zero,
  and every screen renders it through the one `formatAddressTotal()`: the figure
  when it covers everything, the figure marked `plus unpriced tokens` when it
  covers part, and `Total: unpriced tokens only` when it would cover nothing.
  Home, AddressDetail and the removal confirmation all read it, and
  `getWalletValue()`/`getTotalValue()` carry `partial` up. Covered by
  `tests/addressValue.test.js` — the only-unpriced, genuinely-zero and
  fully-priced cases at the helper and at both call sites that return their
  markup — demonstrated failing first
  ([#261](https://git.eeqj.de/sneak/AutistMask/issues/261)).
- 2026-08-17: `README.md` no longer advertises a defect the wallet does not
  have. The End-to-End Tests section listed the EIP-1193 code being dropped in
  the last hop into the page as a standing limit of the dApp coverage; that
  stopped being true when
  [#274](https://git.eeqj.de/sneak/AutistMask/issues/274) landed and did not
  touch the README. The paragraph is deleted and the two remaining limits — the
  stubbed RPC and the unobservable toolbar popup — were checked against the
  current `src/content/inpage.js` and `tests/e2e/` and left as they are
  ([#285](https://git.eeqj.de/sneak/AutistMask/issues/285)).
- 2026-08-17: One transaction approval at a time. Populating in the background
  before the window opens is what makes the displayed object the verified
  object, and it also fixes the nonce: two `eth_sendTransaction` calls populated
  concurrently took the same nonce from a node that had seen neither broadcast,
  and the second could then never be sent, because the only way to give it a
  fresh nonce is to populate it again after the user has read the old one off
  the screen. A second request is now refused with EIP-1193 `-32002` while one
  is unanswered — the slot is taken immediately before population, after the
  authorization checks, so no second nonce is allocated, no second window opens,
  and a page the wallet refuses anyway cannot hold the slot against the
  connected site. The slot is freed at `settleApproval()`, the single point an
  approval is retired, so every path that ends an approval ends the hold with
  it; an approval whose window is gone and whose attempt has failed is settled
  there rather than left waiting on a window that no longer exists, and an
  approval window that could not be opened at all is answered with `-32603`
  instead of holding the page's promise open. Signature approvals are not gated,
  consuming no nonce. A collision that does happen is also reported accurately
  now: a broadcast the node refused for the nonce, and an approval carrying a
  nonce this worker has already broadcast for that address on that chain (caught
  before the node is asked at all), both say the transaction did not reach the
  network and to send it again, instead of warning that it may have sent. The
  record is keyed by chain as well as address, because nonce spaces are per
  chain and low nonces overlap across them. `already known` deliberately keeps
  the ambiguous wording, because a node that says it has the transaction has it
  ([#271](https://git.eeqj.de/sneak/AutistMask/issues/271)).
- 2026-08-14: The parts of the
  [#150](https://git.eeqj.de/sneak/AutistMask/issues/150) and
  [#151](https://git.eeqj.de/sneak/AutistMask/issues/151) definition of done the
  e2e suite did not cover are asserted. It had only shown that the two screens
  open without throwing. Now: the Add Token round trip leaves the navigation
  stack exactly as it found it, read out of extension storage rather than
  inferred from which screen is up, so an orphaned entry — the second-order
  damage of #150 — is caught where it happens rather than one Back press later;
  a common-token quick-pick puts its contract address in the field; the native
  ETH detail path renders with its own type, value and raw quantity and with the
  token contract row still hidden, against a new `seedNativeTransfer` fixture,
  since the normal-transactions endpoint answered `[]` unconditionally and there
  was no non-ERC-20 row to open; and tapping the token contract address puts it
  on the real clipboard, read back after a sentinel write. Each of the four was
  demonstrated failing against a deliberately broken build
  ([#188](https://git.eeqj.de/sneak/AutistMask/issues/188)).
- 2026-08-14: `make check` does static analysis. `script/lint` ran
  `prettier --check .`, byte-identical to `script/fmt-check`, so a wallet with
  two shipped used-but-not-imported crashes behind it was green. ESLint is now
  pinned in `package.json` with `@eslint/js` recommended as the base, flat
  config in `eslint.config.js`, `no-undef` and `no-unused-vars` error-level, and
  globals declared per tree — browser for the popup and content scripts, service
  worker for `src/background/` and `src/shared/`, jest for `tests/`, node for
  `build.js`. It found 41 unused bindings and 53 undefined identifiers; all are
  fixed, and dropping a call to an unimported `foo()` into any `src/` file fails
  `make lint`. Linting is also containerized now: `script/lint` builds the
  Dockerfile's new `lint` stage, so the ESLint that decides whether this repo is
  green is the pinned one and not the host's. The lint stage roughly doubles the
  image build, so `script/test`'s hard timeout is now a bound on a hung suite
  rather than a wall-clock budget: 30s on the host, where the suite runs in
  about 8s, and `AUTISTMASK_TEST_TIMEOUT` raises it inside the image, where a
  cold build pays install and contention costs the policy budget never described
  ([#152](https://git.eeqj.de/sneak/AutistMask/issues/152)).
- 2026-08-14: CI runs the browser end-to-end suites. `.gitea/workflows/e2e.yml`
  runs `script/test-e2e` and `script/test-e2e-firefox` as two jobs on every
  push, separate from `check`, so `make check` and its 20-second `make test` cap
  are untouched. Every browser-level guarantee in this repo — the WASM-under-CSP
  check, the recovery-phrase and private-key DOM wipes, the ConfirmTx spend
  gate, the dApp approval round trips — was enforced only when a human
  remembered to run it by hand. The suites could not run on the runner as they
  stood: the runner executes a job in a container against the host's docker
  daemon, so `docker run -v "$PWD:/work"` mounts an empty directory (measured),
  and the runner image's node cannot install this repo's dependencies. Both
  suites now ship the repo to the daemon as a build context and build the
  extension inside the pinned image, so docker is the only prerequisite on a
  runner or a laptop, and both run the image by ID rather than by tag so
  concurrent clones cannot swap it. The jobs report rather than gate — this repo
  configures no branch protection, and the Chrome suite is measurably flaky
  under load, filed as [#287](https://git.eeqj.de/sneak/AutistMask/issues/287)
  rather than papered over
  ([#259](https://git.eeqj.de/sneak/AutistMask/issues/259)).
- 2026-08-14: A background message handler that throws now rejects the page
  instead of hanging it. `handleRpc(...).then(sendResponse)` had no `.catch()`,
  and `sendResponse` is the only thing that settles the dApp's
  `window.ethereum.request()` promise — so any throw inside `handleRpc` left
  that promise pending forever, with no error and no timeout, indistinguishable
  from a slow wallet. It now answers `{ code: -32603, message }` (the JSON-RPC
  internal error EIP-1474 defines and EIP-1193 defers to; no EIP-1193 4xxx code
  describes "the wallet broke" and none was invented) and logs the method and
  the throw to the background console rather than swallowing them. The two async
  IIFEs behind `AUTISTMASK_TX_RESPONSE` and `AUTISTMASK_SIGN_RESPONSE` were the
  same shape one level down — every statement inside a `try`, but a throw out of
  a `catch` block escaping unhandled — and each got a last-resort `.catch()`
  settling the approval through `settleApproval()` and answering the popup. The
  transaction one tracks which phase it escaped from and reports that, so an
  escape before `broadcastTransaction()` says the request is gone rather than
  that it may still have reached the network. Every other handler on the path is
  synchronous. All four are driven by real failures — a rejecting storage read,
  and a failure classifier that throws while classifying a genuine verification
  or broadcast failure — and were demonstrated failing first, the RPC one with
  `sendResponse` at zero calls
  ([#280](https://git.eeqj.de/sneak/AutistMask/issues/280)).
- 2026-08-14: Approving a site connection is no longer a race against the popup
  closing. The decision now rides the approval port the popup already holds,
  which is the same channel the close disconnects, so it is delivered ahead of
  that disconnect however fast the teardown is; `windows.onRemoved` no longer
  decides a site approval whose port is connected, since that event is ordered
  against nothing either. Rejecting and closing without deciding both still
  report a rejection, and the popup delays its own close by nothing. The e2e
  harness's deferred-`window.close()` accommodation is gone with it, so the two
  site-prompt tests now drive the shipped decide-then-close in a real Chromium;
  against the unfixed code the approval came back to the page as
  `{"settled":"rejected","code":4001}`
  ([#275](https://git.eeqj.de/sneak/AutistMask/issues/275)).
- 2026-08-12: EIP-1193 error codes now reach the page. `src/content/inpage.js`
  rebuilt every failure as `new Error(error.message)`, so the code the
  background produced and the content script relayed intact was dropped in the
  last hop and a dApp checking `err.code === 4001` saw `undefined` — a wallet
  the user deliberately declined was indistinguishable from one that broke. The
  provider now rejects with a `ProviderRpcError` carrying `code` and, where the
  boundary sent one, `data`, passed through verbatim rather than matched against
  a list, so 4001, 4100 and 4902 all arrive and a future code needs no edit
  here. An error the background sent with no code stays a plain `Error` with no
  `code` property, and `message` is unchanged in every case. All four request
  entry points (`request`, `enable`, `send`, `sendAsync`) are covered by
  `tests/inpageErrors.test.js`, and the e2e probe that printed the missing code
  now requires it on the page's Error as well as on the wire, for all four
  rejected flows ([#274](https://git.eeqj.de/sneak/AutistMask/issues/274)).
- 2026-08-12: "Back" now renders the screen it lands on instead of only unhiding
  it. A reopened popup renders the wallet list and the one screen it restores
  onto, so every screen further down the stack was still the blank template from
  `index.html`, and Back walked straight onto it — an empty address, no
  balances, no QR code. The Back path now goes through the same per-view
  dispatch and data guards as the restore (`src/popup/viewRouter.js`, shared
  with `restoreView()`), falling back to Home when the state the target would
  render is gone. It renders only a view this page load has not rendered yet:
  `viewRouter.js` records every view that reaches `showView()`, which is where
  forward navigation and the restore both end, so Back onto a view already on
  the page unhides it and nothing more. That is what keeps a second render from
  re-fetching and overwriting what the view holds — an unsaved edit in Settings,
  a transaction list already loaded. Home is the exception and is always
  re-rendered, as it was before. Covered by unit tests on the real `goBack()`
  and by three end-to-end cases against the real popup, each demonstrated
  failing on the unfixed build
  ([#268](https://git.eeqj.de/sneak/AutistMask/issues/268)).
- 2026-08-12: `KNOWN_SYMBOLS` now maps a symbol to the set of contract addresses
  that bear it, not to one of them. A ticker is not unique: seven of the 512
  bundled tokens — `FRAX`, `REUSD`, `TON`, `EURE`, `MSUSD`, `MUSD` and `JPYC` —
  share a symbol with another bundled entry at a different real contract, and
  the table, built from the list first-wins, kept only the earlier one. The
  other seven were judged spoofs of their own symbol at their own address and
  hidden from the balance list, the history and the send selector, so a holder
  could not spend them. Both contracts of each pair come from the same CoinGecko
  fetch of 2026-02-27, so neither was stale and neither was dropped.
  `isSpoofedSymbol()` asks set membership instead of equality, which does not
  loosen the rule — a contract outside the set is still a spoof — and a test now
  walks `TOKENS` asserting no bundled token is filtered at its own address,
  which is the walk the suite lacked
  ([#276](https://git.eeqj.de/sneak/AutistMask/issues/276)).
- 2026-08-12: The dApp approval round trips are driven end to end in the
  browser. A test page served by the harness speaks EIP-1193 to the real inpage
  provider through the real content script, background worker and approval popup
  for `eth_requestAccounts`, `personal_sign`, `eth_signTypedData_v4` and
  `eth_sendTransaction`. Every signature is recovered and compared against the
  active address, the transaction is checked against the bytes handed to the
  stubbed RPC, each rejection must reach the page as a rejection, and the
  password must appear in no message the approval window sends — the assertion
  that gives [#157](https://git.eeqj.de/sneak/AutistMask/issues/157) a permanent
  floor. This does not discharge a real dApp with real funds against mainnet
  ([#183](https://git.eeqj.de/sneak/AutistMask/issues/183)).
- 2026-08-12: The known-symbol spoof rule now judges the symbol a user actually
  sees. `isSpoofedSymbol()` normalizes before the lookup — NFKC, then every
  character that paints nothing removed (the format and default-ignorable
  characters, plus U+007F), then trimmed — so `" ETH "`, a no-break space, a
  zero-width space, a Hangul filler, a variation selector, a DELETE and a
  fullwidth `ＥＴＨ` are all caught on the balance list, the history and the
  send selector at once. Confusables that are distinct letters (Cyrillic `Е`),
  bidi reordering and the visible C0/C1 controls — which measure 48.00px, a box,
  in the pinned e2e Chromium where an invisible prefix measures 32.00px — stay
  knowingly open and are asserted as open in the suite. No bundled symbol
  contains whitespace or a non-ASCII character, so nothing legitimate is newly
  filtered; the balance list's token-type gate also became case-insensitive,
  which no longer drops a real holding if an explorer writes `erc-20`
  ([#260](https://git.eeqj.de/sneak/AutistMask/issues/260)).
- 2026-08-12: A containerized Firefox end-to-end harness
  (`make test-e2e-firefox`) drives the real popup in a real Firefox with the MV2
  build installed as a temporary add-on. Zero npm dependencies — a WebDriver
  client over `fetch` against geckodriver — with `node`, Firefox 153.0.3 and
  geckodriver 0.36.0 all pinned by digest. Uncaught errors are read from the
  privileged console service in Marionette's chrome context, because BiDi
  `log.entryAdded` reports nothing at all for extension pages; each drain reads
  and clears the console in one chrome round trip, so no error is destroyed
  unread by the drain itself, and errors logged during add-on install and
  background startup are folded into step 1 instead of being cleared. The two
  measured limits are documented rather than claimed away: the console ring
  buffer holds 250 messages (a clean run peaks at 4), and the drained window
  ends ≈1.5s after the last step returns. Demonstrated discriminating by exiting
  1 on a `throw` at the top of `src/background/index.js`, on a build with one
  import removed, on a `setTimeout` throw whose UI assertions all pass, on an
  unhandled `Promise.reject` and on an undefined identifier in `home.js`, and 0
  on the branch as it stands
  ([#184](https://git.eeqj.de/sneak/AutistMask/issues/184)).
- 2026-08-12: The transaction a dApp asks for is now populated in the background
  before the approval window opens, so the object the user is shown is the
  object the signed artifact is verified against — nonce, gas limit and every
  fee field are compared exactly instead of being left to the ceilings, which
  stay as a backstop against what a lying RPC node can talk the wallet into
  displaying. The approval also pins the address it was raised for, so an
  address switch between approval and signing refuses rather than signing from
  an account the screen never named, and a request naming an address that is not
  the active one is refused outright. The approval screen now shows the fee, gas
  limit, network and nonce it vouches for
  ([#216](https://git.eeqj.de/sneak/AutistMask/issues/216)).
- 2026-08-12: The restored navigation stack is filtered against
  `RESTORABLE_VIEWS` on load, truncated at the first entry the popup would not
  render so that every surviving entry keeps the Back target it had. Back after
  reopening can no longer land on a view the popup declined to restore, such as
  `export-privkey` or `show-phrase`
  ([#224](https://git.eeqj.de/sneak/AutistMask/issues/224)). Restorable views in
  the stack are still unhidden without being re-rendered; that is tracked
  separately in ([#268](https://git.eeqj.de/sneak/AutistMask/issues/268)).
- 2026-08-12: One wording for a rejected password on every screen that asks for
  one — the send confirmation and the delete-wallet confirmation no longer say
  "Wrong password." (a fragment, which `RULES.md` Language & Labeling forbids)
  and the two reveal screens no longer say "not correct", so all five
  `decryptWithPassword` call sites now show the sentence the dApp approval paths
  introduced. Strings only, no behaviour change, and each error container
  measured at a 360px viewport in the pinned Playwright container
  ([#172](https://git.eeqj.de/sneak/AutistMask/issues/172)).
- 2026-08-12: Closed the empty-array hole in the end-to-end unstubbed-request
  guard. `batch.every()` is vacuously true on `[]`, so a POST with body `[]` was
  answered `200 []` instead of failing the suite; the guard now rejects an empty
  batch, demonstrated green-before/red-after with a throwaway probe. The comment
  claiming `postData()` returns `null` for undecodable bodies was corrected to
  the two real paths — an absent or empty body decodes to `null`, a binary body
  decodes lossily into invalid JSON
  ([#187](https://git.eeqj.de/sneak/AutistMask/issues/187)).
- 2026-08-12: The transaction confirmation screen has browser coverage. The
  end-to-end suite reaches ConfirmTx for both the native ETH and the ERC-20 path
  off a funded-balance fixture, and asserts the pending, funded, over-balance
  and estimate-failed states, the fee block quoting the estimate and the reserve
  separately, and a constant view height across every one of those transitions.
  The load-bearing assertion is that the spend gate reads the reserve and not
  the displayed estimate: swapping the two fails the suite
  ([#238](https://git.eeqj.de/sneak/AutistMask/issues/238)).
- 2026-08-12: The dust threshold field now explains a rejection instead of
  snapping back in silence, with the parse in a pure, unit-tested module that
  accepts plain decimal digits only — hex and exponent notation are refused
  rather than read as 16 and 1000
  ([#233](https://git.eeqj.de/sneak/AutistMask/issues/233)).
- 2026-08-12: Approval verification became an allowlist — transaction type
  restricted to 0/1/2 so an EIP-7702 delegation can no longer ride along on an
  approved transfer, every consequential field compared, the artifact
  re-serialized from the checked fields alone and its exact bytes required to be
  the canonical encoding of what was broadcast. One approval now yields at most
  one broadcast, and every path that retires a pending approval — popup close,
  active-address change, a late reject — goes through a single chokepoint that
  refuses to settle an attempt already claimed for signing and broadcast
  ([#174](https://git.eeqj.de/sneak/AutistMask/issues/174)).
- 2026-08-12: An address can be removed from an HD or xprv wallet behind a
  confirmation screen that states nothing is destroyed, sharing the deletion
  state transitions with wallet deletion so the selection, site permissions and
  active-address broadcast follow the same rules
  ([#162](https://git.eeqj.de/sneak/AutistMask/issues/162)).
- 2026-08-12: The known-symbol spoof rule moved into `src/shared/symbolSpoof.js`
  and is now the only copy. The balance list had exempted symbols the token list
  maps to `null` — `"ETH"` alone — so a fake ETH ERC-20 was hidden from the
  transaction history and the Send selector but listed as a holding named ETH. A
  symbol with no legitimate contract may now be borne by no contract on any of
  the three surfaces, and the native exemption is "has no contract address", so
  a second null-mapped symbol needs no call-site change. The user's real ETH
  balance is read over RPC and never passes through the rule
  ([#235](https://git.eeqj.de/sneak/AutistMask/issues/235)).
- 2026-08-12: `script/verify-build`'s failure modes are now a committed target,
  `script/test-verify-build`, run by `make check`. It asserts the exit status
  and the message of every case against a fixture tree in a temp dir, and drops
  privileges (proving the runner against a mode-000 file first) for the cases
  that only mean something when file permissions are in force
  ([#227](https://git.eeqj.de/sneak/AutistMask/issues/227)).
- 2026-08-12: WaitTx lifecycle: a receipt and the 60-second timeout can no
  longer both render on one tick, no timer or in-flight lookup outlives its
  wait, a failed receipt lookup no longer counts as a timeout (but six in a row
  end the wait, reported as an unreachable network rather than as a timeout),
  and the wait now resumes after a popup close
  ([#155](https://git.eeqj.de/sneak/AutistMask/issues/155)).
- 2026-08-12: The private key export screen now wipes the key from the page
  whenever it is left by any route, and a decrypt still in flight when the
  screen is left is discarded instead of written; the same `onViewLeave()`
  cleanup was extended to every other screen holding secret material in the DOM
  (AddWallet, ConfirmTx, DeleteWallet, ApproveTx, ApproveSign)
  ([#221](https://git.eeqj.de/sneak/AutistMask/issues/221)).
- 2026-08-12: An xprv wallet already in storage that was imported from a
  non-master key is detected from the depth of its stored `xpub`, explained in
  the wallet list, and blocked from signing, sending and private-key export
  instead of throwing on the send screen
  ([#234](https://git.eeqj.de/sneak/AutistMask/issues/234)).
- 2026-08-12: An unreported `holders_count` is now parsed as `null` rather than
  `0`, so the low-holder rule declines to judge an unknown count instead of
  hiding a legitimate token as spam, in both the transaction history and the
  Send token selector ([#230](https://git.eeqj.de/sneak/AutistMask/issues/230)).
- 2026-08-12: Bundled token list documentation no longer states a count. The
  four "top 250" claims in `README.md` and the "roughly 500" claim in
  `docs/README.md` are replaced with a description of how the list is actually
  selected — a point-in-time CoinGecko snapshot of the highest-market-cap
  Ethereum mainnet ERC-20s — with `TOKENS` in `src/shared/tokenList.js` named as
  the authoritative set
  ([#239](https://git.eeqj.de/sneak/AutistMask/issues/239)).
- 2026-08-11: libsodium runs on WebAssembly in the shipped builds —
  `'wasm-unsafe-eval'` added to both manifest CSPs after measuring the wasm2js
  fallback at 20x the Argon2id cost, pinned in both directions by
  `tests/manifest.test.js` and observed in the real popup by the e2e suite
  ([#182](https://git.eeqj.de/sneak/AutistMask/issues/182)).
- 2026-08-11: Known-symbol spoof verification became a Settings toggle
  (`hideSpoofedSymbols`), on by default, governing the transaction-history
  filter and the fraud-contract learning it feeds
  ([#176](https://git.eeqj.de/sneak/AutistMask/issues/176)).
- 2026-08-11: `script/verify-build` now walks `dist/` NUL-delimited and asserts
  `dist/` is a real directory, so a path with a trailing space or a newline can
  no longer carry a debug marker past the unlisted-bundle check
  ([#223](https://git.eeqj.de/sneak/AutistMask/issues/223)).
- 2026-08-11: UTC Timestamps checkbox moved from the Token Spam Protection well
  into Display, next to the theme selector
  ([#212](https://git.eeqj.de/sneak/AutistMask/issues/212)).
- 2026-08-11: Network fee counted in the confirmation-screen balance check for
  both ETH and ERC-20 sends, reserving what the node actually charges a type-2
  transaction, with the arithmetic in a pure, unit-tested
  `src/shared/txValidation.js`
  ([#154](https://git.eeqj.de/sneak/AutistMask/issues/154)).
- 2026-08-11: A dust threshold of `0` now means "hide nothing" instead of
  falling back to the 100,000 gwei default, and every address comparison in
  `src/shared/transactions.js` goes through one case-normalising helper so a
  checksummed genuine contract is no longer read as a spoof
  ([#179](https://git.eeqj.de/sneak/AutistMask/issues/179)).
- 2026-08-11: Password-gated recovery phrase display for HD wallets, reached
  from the wallet row in Settings, wiped on leaving the screen and excluded from
  the views the popup can reopen onto
  ([#161](https://git.eeqj.de/sneak/AutistMask/issues/161)).
- 2026-08-11: Extended-key import hardened — the base58 checksum is now enforced
  on every xprv and xpub, and a non-master key is refused with an explanation
  instead of being derived beneath
  ([#210](https://git.eeqj.de/sneak/AutistMask/issues/210)).
- 2026-08-11: the balance refresh and the 24-hour phishing list refresh moved
  from `setInterval` to the extension alarms API, with the phishing delta and
  its fetch timestamps persisted to extension storage, so neither job dies with
  the MV3 service worker. Each job's freshness guard was decoupled from its
  alarm period at the same time — timed to the period, a guard vetoes its own
  scheduled tick and halves the real refresh rate
  ([#158](https://git.eeqj.de/sneak/AutistMask/issues/158)).
- 2026-08-11: Policy compliance sweep — conditional verbose test rerun, local
  Tailwind binary instead of `npx`, `--frozen-lockfile` on `make install`, and
  the Makefile-only targets documented in the README
  ([#166](https://git.eeqj.de/sneak/AutistMask/issues/166)).
- 2026-08-11: `script/verify-build` diagnostics corrected: the both-markers
  message now states what is and is not proven, an unreadable bundle is
  diagnosed as an I/O fault rather than as changed output, the `*.js` assumption
  lives only in `build.js`, and the unlisted-bundle scan hard-fails when it
  cannot enumerate `dist/`
  ([#180](https://git.eeqj.de/sneak/AutistMask/issues/180)).
- 2026-08-11: Known-answer test coverage for the crypto core — BIP-39/BIP-32
  derivation in `wallet.js` and the Argon2id vault in `vault.js`
  ([#159](https://git.eeqj.de/sneak/AutistMask/issues/159)).
- 2026-08-11: Three `README.md` claims corrected against the code — blocklist
  attribution, token-display rule, navigation model
  ([#213](https://git.eeqj.de/sneak/AutistMask/issues/213)).
- 2026-08-11: README Screen Map rebuilt from the code — every screen, element
  and transition re-verified against `src/popup/`
  ([#164](https://git.eeqj.de/sneak/AutistMask/issues/164)).
- 2026-08-11: `docs/README.md` rewritten against the code: no competitor names,
  all five network destinations documented, password/Settings/Add Wallet
  sections corrected ([#163](https://git.eeqj.de/sneak/AutistMask/issues/163)).
- 2026-08-11: `loadState()` now derives `hasWallet` from the wallet list instead
  of trusting the persisted flag, so a profile already saved inconsistent no
  longer stays broken on every load
  ([#195](https://git.eeqj.de/sneak/AutistMask/issues/195)).
- 2026-08-11: Wallet deletion repairs its own state — `hasWallet` follows the
  remaining wallets, the selection only moves when it was deleted, and the
  active-address change is broadcast to connected sites
  ([#156](https://git.eeqj.de/sneak/AutistMask/issues/156)).
- 2026-08-11: One row per on-chain value movement in transaction history: the
  merge moved into the pure `mergeTransactions` and the zero-ETH native side of
  a plain ERC-20 transfer absorbed into its token row
  ([#177](https://git.eeqj.de/sneak/AutistMask/issues/177)).
- 2026-08-11: `TODO.md` Workflow rewritten to the branch-and-PR-per-issue model
  on `next`, with Status and Next Step refreshed
  ([#191](https://git.eeqj.de/sneak/AutistMask/issues/191)).
- 2026-08-09: `DEBUG` became a build-time constant defaulting to off, injected
  as the `__BUILD_DEBUG__` esbuild define and turned on with
  `AUTISTMASK_DEBUG=1`, so a plain `make build` no longer hands every newly
  created wallet the publicly committed test recovery phrase
  ([#149](https://git.eeqj.de/sneak/AutistMask/issues/149)).
- 2026-08-09: dApp approval signing moved into the popup — the password no
  longer crosses the extension messaging boundary; the background broadcasts and
  resolves approvals only, and verifies the signed artifact against the approval
  it holds (#157).
- 2026-08-09: Post-build assertion that every emitted bundle containing
  `constants.js` has `DEBUG` compiled off, via `script/verify-build` on the
  `make build` path (#170).
- 2026-08-09: Containerized Chrome end-to-end harness (`make test-e2e` /
  `script/test-e2e`) driving the real popup with all network intercepted, plus
  the two used-but-not-imported crashes it caught: AddToken unreachable (#150)
  and TransactionDetail broken for every ERC-20 transfer (#151). Harness
  demonstrated failing before the fixes and passing after (#181). Interception
  covers the MV3 background service worker, not just the popup page, and a
  launch-time canary aborts the suite if worker traffic starts escaping.
- 2026-08-09: Reviewed the repo end to end and filed the 1.0.0 backlog
  (#149-#168).
- 2026-08-09: Test coverage for the address-poisoning defense in
  `src/shared/transactions.js` (#160)
- 2026-07-26: About well in settings with build info, repo link and the version
  click easter egg (#145); proper view navigation stack (#146).
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
  shims, README Entrypoints section (#148)
- 2026-03-01: USD display suppressed on testnets (#142); estimated USD for ETH
  in approve-tx view (#141).
- Sepolia testnet support (#137); etherscan links go to token-specific URLs
  (#136).
- Transaction detail improvements: Type field and on-chain details (#130),
  txid-first reordering (#133), swap display corrections (#128), expanded
  confirm-tx warnings (#118).
- Dark mode theme setting (Light/Dark/System) with contrast fixes (#126);
  timestamps include timezone offset (#120); layout shift audit, reserved space
  for error messages (#124).
- Copy-flash visual feedback with timing tune (#113, #121); cross-wallet-type
  duplicate detection (#115).
- 2026-02-27: v0.1.0 tagged.
- 2026-02-24: Initial scaffolding: popup UI, BIP-39 wallet creation via
  ethers.js, wallet persistence, real ETH balances over RPC, ENS forward and
  reverse resolution.

# Future Steps

Only work that has no issue of its own belongs here; everything else is on the
tracker.

- Cut 1.0.0 once the milestone is empty, then continue tagging as milestones
  land.
