# Workflow - `git pull` `next` and cut a branch from it — one branch per issue, named `issue--`. 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: 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. An ESLint rule that walks the require graph textually gives the same answer in the editor, before a full bundle; it is fast feedback rather than the guarantee, and the shapes it is known to catch are pinned by `tests/backgroundStateLintRule.test.js`. 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: 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-.zip` and `release/autistmask-firefox-.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 `