Files
AutistMask/TODO.md
sneak bbfbe885cd
All checks were successful
check / check (push) Successful in 33s
e2e / e2e-chrome (push) Successful in 1m47s
e2e / e2e-firefox (push) Successful in 37s
harden: pair every swap amount with the token that supplied it (closes #359)
`src/shared/uniswap.js` gated the token and the amount on truthiness, and
gated them independently. An address is never falsy once set, but an amount
of `0n` is, so a hop supplying a zero amount fixed the token permanently
while leaving the amount open, and the next hop's figure was then displayed
against the first hop's token, at that token's scale.

Input side: a V3 `USDT -> WETH` hop with `amountIn = 0n` followed by a V2
`WETH -> USDC` hop of `0.5e18` rendered `Token In = USDT` with
`Amount = 500000000000.0000 USDT`. Output side: a V3 hop followed by a V4
step with `amountOutMin = 0n` kept `Min. received = 0.5000 WETH` on screen
for a final leg that guarantees nothing.

Both halves are the same gate in the same file and take the same remedy, so
they are one change rather than two statements of one rule.

- One `present()` helper replaces every truthiness gate on a decoded value.
- The input and output sides are each set as a PAIR, never field by field:
  an amount and the token it is counted in always come from the same hop.
  The input side is fixed by the first hop that states either half, the
  output side by the last. A half the establishing hop did not state stays
  null and the line says so.
- A zero slippage floor reads `None (no minimum guaranteed)` rather than
  `0.0000`, which reads as an artifact of the four-decimal rule.
- A V4 `amountIn` of zero is `ActionConstants.OPEN_DELTA` -- v4-periphery's
  `V4Router` substitutes the full open credit for it -- so it reads
  `All available (V4 open delta)`, not `0.0000`, which would have stated the
  exact inverse of what the step does. `amountOutMinimum` gets no such
  mapping and a zero there is a literal floor of zero.

Tests: the two fail-first cases from the issues, two further pairing cases
(a zero minimum against a named output token, and a final leg naming a token
but no minimum), the open-delta amount, and the PERMIT2_PERMIT-only
`execute()` that must invent no output token. Native ETH is pinned on both
sides against the real mainnet fixture and the WRAP_ETH/UNWRAP_WETH paths.

closes #364
2026-08-23 18:34:03 +00:00

68 KiB
Raw Blame History

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. 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, 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 and #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), the twin on the input side of #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). 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.

  • 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). 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: The background no longer reads or writes the shared state singleton (#324), which also closes the cold-worker wrong-chain send (#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 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). 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: 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). tokenInfo() in src/shared/uniswap.js returned decimals: 18 for any such token — the same assumption #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). 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). 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). 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). 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). 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). 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). 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. 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). 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). 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). 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). 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): 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). 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).

  • 2026-08-17: The phishing blocklist is vendored at build time and censored, and the runtime fetch is gone (#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, 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 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).

  • 2026-08-17: One shared extension-API module, 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 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, took storageLocal() directly and kept its own null check; it stores nothing at all as of #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).

  • 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 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).

  • 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).

  • 2026-08-14: The parts of the #150 and #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).

  • 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).

  • 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 rather than papered over (#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).

  • 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).

  • 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).

  • 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).

  • 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).

  • 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 a permanent floor. This does not discharge a real dApp with real funds against mainnet (#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).

  • 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).

  • 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).

  • 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). Restorable views in the stack are still unhidden without being re-rendered; that is tracked separately in (#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).

  • 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).

  • 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).

  • 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).

  • 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).

  • 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).

  • 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).

  • 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).

  • 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).

  • 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).

  • 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).

  • 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).

  • 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).

  • 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).

  • 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).

  • 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).

  • 2026-08-11: UTC Timestamps checkbox moved from the Token Spam Protection well into Display, next to the theme selector (#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).

  • 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).

  • 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).

  • 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).

  • 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).

  • 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).

  • 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).

  • 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).

  • 2026-08-11: Three README.md claims corrected against the code — blocklist attribution, token-display rule, navigation model (#213).

  • 2026-08-11: README Screen Map rebuilt from the code — every screen, element and transition re-verified against src/popup/ (#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).

  • 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).

  • 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).

  • 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).

  • 2026-08-11: TODO.md Workflow rewritten to the branch-and-PR-per-issue model on next, with Status and Next Step refreshed (#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).

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