diff --git a/.dockerignore b/.dockerignore index da592f8..12efda1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,6 @@ +# .git is deliberately NOT excluded: build.js shells out to `git rev-parse` for +# build-info stamping and the Dockerfile runs `make build`, so excluding it +# would make every built extension report commitHash "unknown". node_modules .DS_Store dist diff --git a/Makefile b/Makefile index 33ab6d0..21f2a89 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: bootstrap setup install test lint fmt fmt-check check docker hooks build build-debug clean dev +.PHONY: bootstrap setup install test test-e2e test-e2e-firefox lint fmt fmt-check check docker hooks build build-debug verify-build clean dev # Standard targets are thin shims; the implementations live in script/ # per the scripts-to-rule-them-all pattern (see the Entrypoints section @@ -11,11 +11,18 @@ setup: @script/setup install: - @yarn install + @yarn install --frozen-lockfile test: @script/test +# Browser end-to-end suites. Both require docker; neither is part of check. +test-e2e: + @script/test-e2e + +test-e2e-firefox: + @script/test-e2e-firefox + lint: @script/lint @@ -37,6 +44,7 @@ hooks: build: @echo "Building extension..." @yarn run build 2>&1 + @script/verify-build # Development-only build: enables the red DEBUG / INSECURE banner and makes # the hardcoded test recovery phrase the output of wallet creation. Never @@ -44,6 +52,12 @@ build: build-debug: @echo "Building extension (DEBUG)..." @AUTISTMASK_DEBUG=1 yarn run build 2>&1 + @AUTISTMASK_DEBUG=1 script/verify-build + +# Assert the compiled DEBUG state of the bundles already in dist/. Runs at +# the end of build and build-debug; separate target for re-running it alone. +verify-build: + @script/verify-build clean: @rm -rf dist/ diff --git a/README.md b/README.md index a4023da..cb2733a 100644 --- a/README.md +++ b/README.md @@ -31,10 +31,13 @@ list exists to detect symbol spoofing attacks and improve UX. ```bash git clone https://git.eeqj.de/sneak/autistmask.git cd autistmask -make install +make setup make build ``` +`make setup` is the entrypoint for a fresh clone: it installs dependencies from +the lockfile and installs the git pre-commit hook. + Load the extension: - **Chrome**: Navigate to `chrome://extensions/`, enable "Developer mode", click @@ -59,6 +62,13 @@ behavior. The build prints which mode it used. See the distribute a debug build** — every wallet it creates gets the same publicly known test recovery phrase. +Both builds end by running `script/verify-build`, which reads the compiled +`DEBUG` state back out of the emitted bundles and fails the build if it is not +the one that was asked for. The test suite cannot check this: it loads +`src/shared/constants.js` outside a bundle, so it only ever sees the fallback +value. The assertion is on the artifacts because that is where the property +lives. + ## Entrypoints This repository adheres to the @@ -73,15 +83,247 @@ provide: git pre-commit hook - `script/projectname` — print the project name (used for the Docker image tag) - `script/test` — run the test suite (jest) +- `script/test-e2e` — run the Chrome browser end-to-end suite (docker required; + see [End-to-End Tests](#end-to-end-tests)) +- `script/test-e2e-firefox` — run the Firefox browser end-to-end suite (docker + required; builds its own pinned image, see + [End-to-End Tests](#end-to-end-tests)) - `script/lint` — run the linter - `script/fmt` — format all files (writes) - `script/fmt-check` — check formatting (read-only) -- `script/check` — run test, lint, and fmt-check +- `script/check` — run test, test-verify-build, lint, and fmt-check +- `script/verify-build` — assert the compiled `DEBUG` state of the bundles in + `dist/`: every bundle containing `src/shared/constants.js` must have `DEBUG` + off, or on when `AUTISTMASK_DEBUG=1`. Run automatically at the end of + `make build` and `make build-debug`; fails loudly rather than passing if it + cannot determine a bundle's state. Not part of `make check`, which does not + depend on build artifacts existing. +- `script/test-verify-build` — exercise every failure mode of + `script/verify-build` against a fixture tree in a temp dir, asserting the exit + status and the message of each. Part of `make check`; it reads no build + artifacts and writes nothing under `dist/`. The cases that depend on file + permissions cannot mean anything for a process that is not subject to them, so + the harness proves its runner against a mode-000 file before counting them, + dropping to an unprivileged user when run as root; if it cannot, it skips + those cases and says so in a banner rather than passing them. - `script/docker` — build the Docker image tagged via `script/projectname` - `script/cibuild` — CI entrypoint: plain `docker build .` - `script/precommit` — run by the git pre-commit hook; runs `script/check` - `script/install-precommit` — install the git pre-commit hook +The Makefile shims to those. It also carries a few targets that have no +`script/` counterpart and are Makefile-only conveniences: + +- `make install` — `yarn install --frozen-lockfile` on its own, without the rest + of `script/bootstrap`. Frozen so a stale `yarn.lock` fails instead of being + silently rewritten. Use `make setup` for a fresh clone. +- `make hooks` — shims to `script/install-precommit` +- `make build` — build the extension into `dist/chrome/` and `dist/firefox/` +- `make build-debug` — the same build with `AUTISTMASK_DEBUG=1` (see + [Debug Builds](#debug-builds)) +- `make clean` — remove `dist/` +- `make dev` — build in watch mode + +## End-to-End Tests + +There are two suites, one per browser, and they share no code. Chrome runs on +Playwright; Firefox has its own WebDriver client, because Playwright cannot +observe errors on a Firefox extension page at all — see +[Firefox](#firefox-make-test-e2e-firefox) below. Both require docker, and both +are outside `make check`. + +### Chrome (`make test-e2e`) + +`make test-e2e` builds `dist/chrome/` and drives the **real popup in a real +Chrome**, loaded as an unpacked MV3 extension inside a pinned +`mcr.microsoft.com/playwright` container (pinned by digest in `script/test-e2e`; +docker is required and the suite fails loudly rather than skipping if it is +unavailable). The suite lives in `tests/e2e/` and is driven by +`playwright-core`, whose version must stay matched to the container's Playwright +version — the browsers ship inside the image. + +It covers popup load, WebAssembly compilation under the shipped CSP (see +[Content Security Policy](#content-security-policy)), wallet creation through +the UI, the Add Token screen, the transaction detail screen for an ERC-20 +transfer, and the recovery phrase screen — which wallet types are offered it, +that it holds nothing before the password is accepted, that a wrong password +reveals nothing, that leaving it by either route wipes it — including a leave +taken while the decrypt is still running — and that reopening the popup does not +land on it. It also covers address removal: which wallets offer the control at +all, that the confirmation states the route back rather than showing an empty +paragraph, that leaving the confirmation removes nothing, and that confirming it +does. All outbound network is intercepted at the browser level and served from +fixtures in `tests/e2e/network.js`, so the run is deterministic and fully +offline; unrecognised outbound requests are reported as failures rather than +silently allowed. + +It also covers the confirmation screen, for both a native ETH send and an ERC-20 +send: Send disabled while the fee estimate is in flight, enabled once it lands, +the fee block quoting the expected cost and the reserve separately, the distinct +message for an estimate that failed, and the view height staying constant across +every one of those transitions. The load-bearing one is that the spend gate uses +the **reserve** and not the displayed **estimate** — the two are stubbed far +apart on purpose, and the funded and refused sends sit on opposite sides of the +reserve while sitting on the same side of the estimate, so swapping the two in +`src/popup/views/confirmTx.js` fails the suite instead of passing it. That is +what [#154](https://git.eeqj.de/sneak/AutistMask/issues/154) was, and it was +previously correct by reading only. + +It also covers the **dApp approval round trips** — the one place where the +content script, the inpage provider, the background worker and the approval +popup all have to work together. A local test page is served by the route +handler on a reserved-TLD origin, gets `window.ethereum` from the shipped +`MAIN`-world content script like any other page, and drives +`eth_requestAccounts`, `personal_sign`, `eth_signTypedData_v4` and +`eth_sendTransaction` through the real prompts. Every signature is recovered in +the runner and compared against the active address, the transaction assertions +run against the raw signed transaction captured at `eth_sendRawTransaction` +rather than against anything the extension reported, rejecting each prompt is +required to return a rejection to the page rather than hang or resolve, and the +password is required to be absent from every message the approval window sends +to the background — with the message that would carry it required to be present, +so that check cannot pass by observing nothing. That last one is the standing +floor under [#157](https://git.eeqj.de/sneak/AutistMask/issues/157). + +Three limits of that coverage, none of them papered over. The RPC is stubbed +throughout, so this is **not** a real dApp against a real network with real +funds; that remains a human pass before 1.0.0. The site-connection prompt is +raised through `chrome.action.openPopup()`, and headless Chromium's +browser-action popup is not a page Playwright can see or click, so that one +prompt is driven at the URL the extension itself puts on the action — the same +page and the same approval id, but whether a real toolbar click shows it is not +observable here. And the EIP-1193 error code does not survive the last hop: the +rejection that crosses the boundary carries code 4001 and is asserted to, but +`src/content/inpage.js` rebuilds it as `new Error(message)`, so the calling page +catches an error with no `code` property. + +Any test that drives a failure path on purpose declares the `console.error` it +is about to provoke, via `errors.expect()`. That is not a mute: the declaration +consumes exactly one matching record, and a declaration nothing matched fails +its test just as an undeclared error does. + +That reporting has one bound worth knowing. Observation ends when the browser +context is torn down, and nothing can watch traffic after that, so the run keeps +collecting for a fixed grace period after the last test returns +(`TRAILING_WATCH_MS` in `tests/e2e/run.js`, currently 1500ms) and then closes +the context. A request whose _first_ dispatch falls after that window is never +seen at all and cannot fail the run. In practice a request a test fires without +awaiting reaches the route handler about 10ms later, and anything on a repeating +timer gets observed on an earlier tick during the ~20s suite — but a one-shot +call deliberately deferred past the window will escape. + +That interception covers the MV3 background service worker as well as the popup +page, which it does not by default — `script/test-e2e` sets +`PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1` for it. Because that flag is +experimental, the harness does not take it on trust. At launch it waits for the +background worker's **own** startup request — the phishing blocklist fetch that +`src/background/index.js` issues on startup, which on the suite's throwaway +profile always happens because no previous fetch timestamp is persisted — to +arrive in the route handler, and aborts the entire suite if none does within 30 +seconds (`tests/e2e/harness.js`). The check is passive on purpose: a synthetic +probe fetched from inside the worker via `worker.evaluate()` was tried first and +rejected, because evaluating in an extension service worker that early kills the +worker outright, destroying the thing being measured. Observing traffic the +extension already generates perturbs nothing. Losing the race fails closed — the +suite refuses to run rather than passing quietly. + +As defence in depth, Chrome is also started with +`--host-resolver-rules=MAP * ~NOTFOUND`, so a request that ever did slip past +the route handler could not resolve a host at all. That only bounds the damage; +detecting escaping traffic remains the canary's job. To see what is actually +being intercepted, run with `E2E_TRACE_NETWORK=1` and every routed request is +printed, tagged `[sw]` or `[page]`. + +**Any uncaught page error or `console.error` fails the run.** That is the point: +a `ReferenceError` from a used-but-not-imported identifier is invisible to +`make check` (`script/lint` is only `prettier --check`) but fatal in a browser, +and this suite exists because exactly that class of bug shipped twice. + +### Firefox (`make test-e2e-firefox`) + +`make test-e2e-firefox` builds `dist/firefox/` and drives the **real popup in a +real Firefox**, installed as an unpacked MV2 temporary add-on via geckodriver. +It covers popup load, wallet creation through the UI, and the Add Token screen. +The suite lives in `tests/e2e/firefox/` and has **no npm dependencies at all**: +it is a small WebDriver client built on global `fetch` and `child_process` +against geckodriver's HTTP API. + +Unlike the Chrome suite it builds its own container image rather than pulling a +published one, because no published image carries both a pinned Firefox and a +matching geckodriver. `tests/e2e/firefox/Dockerfile` pins all three external +artifacts by digest — the `node` base image, the Firefox 153.0.3 tarball, and +geckodriver 0.36.0 — and the Firefox version in particular must not float: +`-remote-allow-system-access` is **mandatory** on 153 and was not on 142. +Without that flag, both navigating to `moz-extension://` and running +chrome-context script fail with `unsupported operation`. The flag grants the +driver full chrome privileges over that browser, which is acceptable only +because it is a throwaway container. + +The popup's `moz-extension://` uuid is **pinned, not discovered**: the profile +pref `extensions.webextensions.uuids` maps the extension id that +`manifest/firefox.json` already declares to a fixed uuid, so the popup URL is +deterministic. Navigation uses **classic** WebDriver `POST /session/{id}/url`, +because BiDi's `browsingContext.navigate` refuses `moz-extension://` outright. + +**Any uncaught error from a `moz-extension://` source fails the run**, including +errors from the background page, which the suite never navigates to: a `throw` +at the top of `src/background/index.js` kills the background page and fails +step 1. Content-script errors should arrive by the same route, but this suite +does not exercise it and does not claim it — with `--network none` there is no +`http://` page for a content script to be injected into. Errors from add-on +install and background startup are folded into step 1 rather than discarded. +Errors are read from the privileged `nsIConsoleService` in Marionette's chrome +context and filtered to non-warning entries whose `sourceName` is the extension +origin. That mechanism is not a stylistic choice. WebDriver BiDi's +`log.entryAdded` delivers **nothing** for extension pages: on a plain `http://` +page it reports uncaught errors with stack traces, and on the `moz-extension://` +popup it reports zero events, because Firefox's remote agent excludes extension +browsing contexts from BiDi observation. Any harness built on Playwright-BiDi or +Puppeteer-BiDi would therefore see nothing and report success, which is exactly +the vacuous check this repo has already shipped twice. Do not migrate this suite +to BiDi. + +Two limits are worth knowing, both real differences from the Chrome suite: + +- **Error capture is poll-based, not event-streamed.** The console is drained at + each step boundary, so an error is attributed to the step it was drained + after, not to a moment within it. The window that is drained runs from add-on + install to **≈1.5s** after the last step returns — a 500ms settle, a 1000ms + tail sleep and two drain round trips — and then the browser is torn down. That + cut-off is not a hard boundary: with throws scheduled at fixed offsets, three + runs reported everything up to +1.5s and one of the three also reported +1.6s, + so an error landing near it may or may not be seen, and anything well past it + is not. Inside the window there is no race — each drain reads and clears the + console in a single chrome round trip, so an error logged mid-drain lands in + that batch or the next rather than being destroyed unread — but there is a + **capacity limit**: `nsIConsoleService` keeps a ring buffer of 250 messages + and silently evicts the oldest, so more than 250 console messages between two + drains destroys the excess unread. 400 throws inside one step are reported as + exactly the newest 250, three runs running. That buffer is shared with + Firefox's own console noise; a clean run peaks at 4 of 250 at the install + drain and 0 at every later drain, so the three steps here have wide headroom, + but a step that logs heavily could evict unread errors. What poll-based costs + is location, not coverage: an error cannot be placed within a step the way the + Chrome suite's `pageerror` events place it. +- **Nothing is stubbed, which inverts the coverage of network-dependent code.** + There is no fixture layer; the container runs with `--network none` instead, + so the run is offline and deterministic and no request can escape. The + extension swallows its own fetch failures, so the flows are unaffected — but + every network call fails, so only the _failure_ branches of code that depends + on one are ever executed. A `ReferenceError` in the success path of + `renderTransactions`, or of price or balance rendering, passes this suite + green. The offline run is also weaker than the Chrome suite's interception: it + proves nothing got out, but it cannot report which requests were attempted. + Closing that gap needs a fixture layer, deliberately out of scope for this + harness. + +Neither `make test-e2e` nor `make test-e2e-firefox` is part of `make check` or +`make test`. `REPO_POLICIES.md` caps `make test` at 20 seconds and a browser +suite does not fit; nothing in `tests/e2e/` is named `*.test.js`, so jest cannot +pick it up either. Neither is wired into the Gitea workflow yet — +docker-in-docker in CI is a separate question. Run them locally before changing +anything under `src/popup/views/`. + ## Rationale Common popular EVM wallets have become bloated with swap UIs, portfolio @@ -117,12 +359,14 @@ src/ styles/main.css — Tailwind source views/ — one JS module per screen (home, send, approval, etc.) shared/ — modules used by both popup and background + alarms.js — recurring background jobs (extension alarms API) balances.js — ETH + ERC-20 balance fetching via RPC + Blockscout constants.js — chain IDs, default RPC endpoint, ERC-20 ABI - ens.js — ENS forward/reverse resolution + ens.js — ENS forward/reverse resolution (popup only) prices.js — ETH/USD and token/USD via CoinDesk API scamlist.js — known fraud contract addresses state.js — persisted state (extension storage) + symbolSpoof.js — the known-symbol spoof rule, shared by all surfaces tokenList.js — top ERC-20 tokens by market cap (hardcoded) transactions.js — tx history fetching + anti-poisoning filters uniswap.js — Uniswap Universal Router calldata decoder @@ -133,6 +377,74 @@ manifest/ firefox.json — Manifest V2 for Firefox ``` +### Background scheduling + +Chrome runs `src/background/index.js` as a Manifest V3 service worker, which the +browser terminates after roughly 30 seconds idle and re-evaluates from scratch +on the next event. Two consequences shape every recurring job in the background: + +- `setInterval` and `setTimeout` are useless. They are destroyed with the + worker, so a job scheduled that way runs until the first idle period and never + again. Both recurring jobs — the 60-second balance refresh and the 24-hour + phishing blocklist refresh — are scheduled through the extension alarms API + (`src/shared/alarms.js`) instead. The browser holds the schedule and wakes the + worker to deliver it. Alarm periods are clamped to a one-minute minimum, so + the balance refresh is expressed as exactly one minute and nothing is silently + slowed down. +- Module-level variables do not survive either. Anything that must be remembered + across a restart goes in extension storage, including the timestamp of the + last phishing list fetch: without it a revived worker would either re-fetch on + every wake or, with a naive in-memory guard, never notice that an update is + due. `localStorage` does not exist in a service worker at all — the one + remaining user of it, `src/shared/ens.js`, runs only in the popup and is + marked as such. + +Both jobs also carry a freshness guard, and a guard must never be timed to the +alarm period it gates. Each guard is measured from the moment the last run +finished, which is one run-duration after the alarm that started it, so a guard +of exactly one period vetoes the very next tick and the real cadence becomes two +periods. The two jobs solve this differently, because their guards exist for +different reasons: + +- The phishing refresh has a 24-hour cache TTL whose job is to keep the worker + off the network on the wakes between scheduled refreshes — Chrome revives the + worker every ~30 seconds while the browser is busy, and every revival runs the + startup path. The scheduled alarm tick is not one of those wakes, so it + bypasses the TTL and fetches unconditionally. Shortening the TTL instead would + not work: the startup path re-checks it on every wake, so a shorter TTL simply + becomes the real refresh rate. +- The balance refresh guard exists to skip work an open popup has already done — + the popup refreshes every 10 seconds and stamps the same field. That has to + keep applying on the scheduled tick, so the guard is shortened to half the + alarm period instead of bypassed: comfortably above the popup's 10 seconds, so + an open popup still suppresses the background job, and comfortably below the + 60-second period, so the schedule always wins. + +Two timestamps are persisted for the phishing list, not one. `lastFetchTime` +records a fetch that produced a usable delta and drives the TTL. +`lastAttemptTime` records that the network was contacted at all, and is written +even when the result is unusable — a failed request, or a delta over the 256 KiB +cap. Without it those cases leave no freshness mark and the worker re-downloads +the full blocklist on every wake, indefinitely; with it, unscheduled retries are +floored at one hour. Both are discarded on load if they are in the future, since +a stamp from a skewed clock or a restored backup would otherwise suppress +updates until that time arrives, permanently and with no way out. + +The startup path (`ensureRecurringAlarms()` plus the phishing list init) runs on +`onInstalled`, on `onStartup`, and at the top level of the worker, so every way +the background context can start re-establishes the schedule. On a fresh install +more than one of those fires, so they share a single in-flight run rather than +racing. It is idempotent: an alarm that already exists with the period the code +asks for is left alone, because re-creating one restarts its schedule and a busy +extension would push the next fire out indefinitely. An alarm carrying a +different period — one created by an earlier version — is re-created once, or a +period changed in a new release would never reach an existing install. + +Firefox uses Manifest V2 with a persistent background page, where timers would +survive. Both browsers are built from one bundle and both take the alarm path, +so there is a single code path to reason about; `"alarms"` is declared in both +`manifest/chrome.json` and `manifest/firefox.json`. + ### UI Design Philosophy The UI is inspired by _Universal Paperclips_. It's deliberately minimal, @@ -196,10 +508,10 @@ on a different table knows exactly tf I am talking about. Every interactive element must visually indicate that it is clickable. Buttons use a visible border, padding, and a hover state (invert to white-on-black). -Text that triggers an action (e.g. "Import private key") uses an underline. No -invisible hit targets, no bare text that happens to have a click handler. If it -does something when you click it, it must look like it does something when you -click it. +Text that triggers an action (e.g. "Add additional wallet...") uses an +underline. No invisible hit targets, no bare text that happens to have a click +handler. If it does something when you click it, it must look like it does +something when you click it. #### Display Consistency @@ -259,115 +571,219 @@ attack. The core hierarchy is **Wallets → Addresses**: -- A **wallet** is either: - - An **HD wallet** (recovery phrase): generates multiple addresses from a - single 12/24 word recovery phrase using BIP-39/BIP-44 derivation. The user - can add more addresses with a "+" button. - - A **key wallet** (private key): a single address imported directly from a - private key. No "+" button since there is only one address. -- An **address** holds ETH and any user-added ERC-20 tokens. +- A **wallet** is one of three types: + - An **HD wallet** (`type: "hd"`, recovery phrase): generates multiple + addresses from a single 12/24 word recovery phrase using BIP-39/BIP-44 + derivation. The user can add more addresses with a "+" button. + - A **key wallet** (`type: "key"`, private key): a single address imported + directly from a private key. No "+" button since there is only one + address. + - An **xprv wallet** (`type: "xprv"`, extended private key): the same + multi-address behavior as an HD wallet, including the "+" button and the + address scan on import, but imported from an extended private key rather + than a recovery phrase. It therefore has no recovery phrase to display or + back up. Only a master key may be imported; an xprv wallet already in + storage that was imported from a non-master key is detected from the depth + of its stored `xpub` by `src/shared/walletDefects.js`, explained in the + wallet list, and blocked from signing, sending and private-key export. It + is never deleted or rewritten. +- An **address** holds ETH and ERC-20 tokens. - The user can have multiple wallets, each with multiple addresses (HD) or a single address (key). +Which tokens an address shows is decided by `fetchTokenBalances()` in +`src/shared/balances.js`, from the Blockscout `token-balances` response, so +tokens do appear without the user adding them. An ERC-20 is shown when its +balance is nonzero and it is in the bundled known-token list, is tracked by the +user, or has 1,000 or more holders; a token claiming a symbol from the bundled +list from any other contract address is always dropped, and so is any token +claiming a symbol that belongs to the native asset and therefore has no +legitimate contract at all (`"ETH"`). That filter is unconditional — the "Hide +tokens with fewer than 1,000 holders" setting governs the transaction history +and the send-screen token selector, not this list. Tracked tokens with a zero +balance are listed as well while "Show tracked tokens with zero balance" is on. + #### Navigation The main view shows all addresses grouped by wallet, with ETH balances inline. The user taps an address to see its detail view (full address, balance, tokens, -send/receive). Navigation is flat — every view has a "Back" or "Cancel" button -that returns to the previous context. No deep nesting, no tabs, no hamburger -menus. +send/receive). Navigation is a stack: each forward action pushes the current +screen, and every view has a "Back" or "Cancel" button that pops back to it (see +the Screen Map below). There is no hamburger menu and no persistent tab bar; the +Settings gear in the title bar is the only global control. Two screens carry an +in-screen control beyond that: AddWallet uses three tabs to select the import +mode, and AddressDetail keeps its one rarely-used action ("Export Private Key") +behind a "···" menu. ### Screen Map -Navigation uses a stack model (like iOS): each action pushes a screen onto the -stack, and "Back" pops it. The root screen is either Welcome (no wallets) or -Home (has wallets). Screens are listed below with their elements and -transitions. +Navigation uses a stack model (like iOS): each forward action pushes the current +screen onto `state.viewStack`, and "Back" pops it (`pushCurrentView()` and +`goBack()` in `src/popup/views/helpers.js`). The root screen is either Welcome +(no wallets) or Home (has wallets). Each screen below gives its view id in +parentheses; the registry of view ids is the `VIEWS` array in +`src/popup/views/helpers.js`, and the markup for a screen is the element with id +`view-` plus that view id in `src/popup/index.html`. -#### Welcome +Three elements sit outside the screens and are present on all of them: the title +bar ("AutistMask by @sneak" plus the Settings gear), the flash message line +under it, and the red banner at the very top that appears on a debug build, when +runtime debug mode is on, or when the active network is a testnet. They are not +repeated in the element lists below. -- **When**: No wallets exist yet. -- **Elements**: "AutistMask" heading, brief intro text, "Add wallet" button. +Closing and reopening the popup returns to the screen the user was last on only +for the views listed in `RESTORABLE_VIEWS` (`src/popup/restorableViews.js`). +Every other screen falls back to Home. The screens that display a secret — +ExportPrivKey and ShowRecoveryPhrase — are deliberately absent from that list, +so the popup can never reopen onto one of them with no password prompt in front +of it. + +A reopened popup renders the wallet list and the one screen it restores onto, +and nothing else, so every screen on the stack behind that one is still the +blank template from `index.html`. "Back" therefore renders its target rather +than only unhiding it, through the same dispatch and data guards as the restore +(`src/popup/viewRouter.js`), and falls back to Home when the state the target +would render is gone. + +It renders only a screen this page load has not rendered yet. Forward navigation +renders as it goes, and `viewRouter.js` records every screen that reaches +`showView()`, so "Back" onto a screen already on the page unhides it and nothing +more — rendering it a second time would re-fetch and overwrite what it holds, +such as an edit typed into Settings and not yet saved. Home is the one screen +"Back" always re-renders, so the wallet list reflects anything that changed +while the user was away from it. + +Every screen that holds secret material in the page registers a cleanup with +`onViewLeave()` (`src/popup/views/helpers.js`), which `showView()` runs on every +exit from that screen rather than only on its "Back" button, so nothing secret +survives in a hidden view once the user has navigated away by any route. That +covers the revealed private key and recovery phrase, the recovery phrase, +private key or extended private key entered on AddWallet, and the password typed +on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign. + +#### Welcome (`welcome`) + +- **When**: No wallets exist yet (`state.hasWallet` is false). This is the root + screen in that case. +- **Elements**: + - "Welcome! To get started, add a wallet." text + - "Add wallet" button - **Transitions**: - "Add wallet" → **AddWallet** -#### Home +#### Home (`main`) - **When**: At least one wallet exists. This is the root screen. - **Elements**: - - Header: "AutistMask", Settings gear button - - Active address ETH balance (large) + USD value (inline parentheses) - - Total USD value across all tokens (small text) + - Active address ETH balance (large) + USD value in parentheses + - "Total:" USD value across ETH and every token shown for the active address - Active address (color dot, full address, etherscan link, tap to copy) - - Send / Receive quick-action buttons + - Send / Receive quick-action buttons, both acting on the active address - ETH/USD price display - - Wallet list: each wallet shows name (tap to rename), "+" button (HD only), - and its addresses with color dots, balances, and `[info]` buttons - - Recent transactions across all addresses (merged, deduplicated, filtered) + - Wallet list: each wallet shows its name (tap to rename inline) and a "+" + button for HD and xprv wallets, then one block per address with "Address + N" (bold when active), the ENS name if resolved, the full address, an + `[info]` button, an `[x]` button (only on HD and xprv wallets holding more + than one address), the address USD total, and a balance line for ETH and + for each token shown for that address + - "Recent Transactions": up to 25 transactions merged across every address + of every wallet, deduplicated by hash and filtered - "Add additional wallet..." link at bottom - **Transitions**: - - Tap address row → sets active address (no screen change) + - Tap address row → sets the active address and broadcasts + `AUTISTMASK_ACTIVE_CHANGED` (no screen change) + - Tap wallet name → inline rename field (no screen change) + - "+" on wallet → derives the next address inline (no screen change) - `[info]` on address → **AddressDetail** - - "Send" → **Send** (selects active address) + - `[x]` on address → **DeleteAddress** + - "Send" → **Send** (refuses with a flash message on a zero balance) - "Receive" → **Receive** (shows active address QR) - - "+" on wallet → derives next address inline + - Tap home tx row → **TransactionDetail** - "Add additional wallet..." → **AddWallet** - Settings gear → **Settings** (toggles; tap again to return) - - Tap home tx row → **AddressDetail** (for the address involved) -#### AddWallet +#### AddWallet (`add-wallet`) -- **When**: User wants to add a new wallet (from Home, Welcome, or Settings). +- **When**: User wants to add a new wallet (from Welcome, Home, or Settings). + This one screen covers all three import modes; there is no separate import + screen. - **Elements**: - - "Add Wallet" heading, "Back" button - - Instruction text - - Die button `[die]` (generates random recovery phrase) - - Recovery phrase textarea - - Backup warning box (shown after die is clicked) - - Password + confirm password inputs - - "Add" button - - "Have a private key instead?" link -- **Transitions**: - - "Add" (valid phrase + password) → **Home** - - "Back" → previous screen (Home or Welcome) - - "Have a private key instead?" → **ImportKey** - -#### ImportKey - -- **When**: User wants to import a single private key. -- **Elements**: - - "Import Private Key" heading, "Back" button - - Instruction text - - Private key input (password-masked) - - Password + confirm password inputs + - "Back" button, "Add Wallet" heading + - Three tabs — "From Phrase" (`tab-mnemonic`), "From Key" (`tab-privkey`), + "From xprv" (`tab-xprv`) — each showing its own form section: + - **From Phrase**: instruction text, a die button that generates a + random recovery phrase, a recovery phrase textarea, and a backup + warning box that becomes visible once the die button has been used + - **From Key**: instruction text and a masked private key input + - **From xprv**: instruction text and a masked extended private key + input + - Password + confirm password inputs, with a hint line whose wording depends + on the selected tab - "Import" button - **Transitions**: - - "Import" (valid key + password) → **Home** - - "Back" → **AddWallet** + - "Import" with a valid entry and a matching password of at least 12 + characters → creates the wallet, clears the navigation stack, and → + **Home**. The phrase and xprv modes then scan for further used addresses + and report the count as a flash message. + - "Import" with an invalid entry, a duplicate wallet or address, or a short + or mismatched password → flash message, no screen change + - "Back" → previous screen (Welcome, Home, or Settings) -#### AddressDetail +#### AddressDetail (`address`) - **When**: User tapped `[info]` on an address from Home. - **Elements**: - "Back" button - Blockie identicon (48px, centered) - Title: "Wallet Name — Address N" - - ENS name (if resolved, bold with color dot) + - ENS name (if resolved, bold above the address) - Full address (color dot, etherscan link, tap to copy) - USD total for address - - Balance list: ETH + tracked ERC-20 tokens (4 decimal places, USD inline). - Each balance row is clickable → **AddressToken** - - Send / Receive / + Token buttons + - Balance list: ETH + the ERC-20 tokens shown for this address (4 decimal + places, USD inline). Each balance row is clickable → **AddressToken** + - Send / Receive / + Token buttons and a "···" menu button + - "···" dropdown containing a single "Export Private Key" entry - Transaction list (with ENS resolution for counterparties) - **Transitions**: - Tap balance row → **AddressToken** (for that token) - - "Send" → **Send** + - "Send" → **Send** (refuses with a flash message on a zero balance) - "Receive" → **Receive** - "+ Token" → **AddToken** + - "···" → "Export Private Key" → **ExportPrivKey** - Tap transaction row → **TransactionDetail** - - "Back" → **Home** + - "Back" → previous screen (Home) -#### AddressToken +#### ExportPrivKey (`export-privkey`) + +- **When**: User chose "Export Private Key" from the "···" menu on + AddressDetail. This screen discloses secret material. +- **Elements**: + - "Back" button + - Blockie identicon (48px, centered) + - "Export Private Key" heading + - "Wallet Name — Address N" and the full address (etherscan link, tap to + copy) + - Warning that anyone holding the private key can transfer all funds from + the address + - Error line + - Password input and "Reveal" button, shown until the key is revealed + - The private key on a highlighted background, tap to copy, shown only after + the password has been accepted +- **Transitions**: + - "Reveal" (correct password) → decrypts the wallet secret, derives this + address's key, hides the password input and shows the key (no screen + change) + - "Reveal" (wrong password) → full-sentence error on the error line, nothing + revealed (no screen change) + - "Back" → previous screen (AddressDetail) +- **Secret handling**: nothing is decrypted, no key is derived, and nothing is + written into the page until the password is accepted; the key is never stored + in state, and it is wiped from the page whenever the screen is left by any + route, including the Settings gear. A decrypt still running when the screen is + left is discarded rather than written. The screen is not restorable, so + reopening the popup lands on Home rather than back on the key. + +#### AddressToken (`address-token`) - **When**: User clicked a specific token balance on AddressDetail. - **Elements**: @@ -378,49 +794,74 @@ transitions. - USD total for this token - Single token balance line (4 decimal places) - Send / Receive buttons + - Token contract well (ERC-20 only): full contract address (tap to copy, + etherscan link) plus name, symbol, decimals, holder count and project + website where known - Token-filtered transaction list (only this token's transfers) - **Transitions**: - - "Send" → **Send** (token pre-selected and locked in dropdown) + - "Send" → **Send** (token locked: the dropdown is replaced by a static + symbol and contract address) - "Receive" → **Receive** (ERC-20 warning shown for non-ETH tokens) - Tap transaction row → **TransactionDetail** - - "Back" → **AddressDetail** + - "Back" → previous screen (AddressDetail) -#### Send +#### Send (`send`) -- **When**: User wants to send ETH or a token from this address. +- **When**: User wants to send ETH or a token, from Home, AddressDetail, or + AddressToken. - **Elements**: - - "Send" heading, "Back" button + - "Back" button, "Send" heading - From: address with color dot + etherscan link - What to send: token dropdown (or static display with contract address when locked from AddressToken) - - To: address or ENS name input + - To: address or ENS name input, with an inline validation message - Amount input with current balance display - - "Review" button + - "Review" button, disabled until the recipient validates - **Transitions**: - "Review" (valid inputs, ENS resolved) → **ConfirmTx** - - "Back" → **AddressToken** (if came from token view) or **AddressDetail** + - "Review" with an unresolvable ENS name or an invalid amount → flash + message, no screen change + - "Back" → previous screen (Home, AddressDetail, or AddressToken) -#### ConfirmTx +#### ConfirmTx (`confirm-tx`) - **When**: User reviewed send details and is ready to authorize. - **Elements**: - - "Confirm Transaction" heading, "Back" button + - "Back" button, "Confirm Transaction" heading - Type: "Native ETH transfer" or "ERC-20 token transfer (SYMBOL)" - Token contract: full address + etherscan link (ERC-20 only) - From: blockie + color dot + full address + etherscan link + wallet title - To: blockie + color dot + full address + etherscan link + ENS name - Amount: value + symbol (USD in parentheses) - Your balance: value + symbol (USD in parentheses) - - Estimated network fee: ETH amount (USD in parentheses), fetched async - - Warnings (scam address, self-send) - - Errors (insufficient balance) - - "Send" button (disabled if errors) + - Network fee: "Estimating..." then two lines, or "Unable to estimate", + fetched async. The first line is what the transfer is expected to cost, + `gasLimit * gasPrice` (USD in parentheses); the second is the + `gasLimit * maxFeePerGas` reserve the node requires, which is what the + balance check gates on. The second line is omitted on a network with no + type-2 pricing, where the two are the same number, but its space is + reserved either way + - Warnings: inline warnings from the local checks (scam address, self-send) + plus four reserved warning boxes made visible by the async checks — + recipient with no transaction history, recipient is a contract, burn + address, and an Etherscan phishing/scam label + - Errors (insufficient balance), plus three reserved error boxes — the + amount plus the fee exceeds the balance (ETH transfers), not enough ETH to + pay the fee for the transfer (ERC-20 transfers), and the fee could not be + estimated. The first two are mutually exclusive per transfer type, so only + the applicable one holds space + - Password: an inline field on this screen, not a modal, with its own error + line + - "Sign & Send" button (disabled if errors, and while the network fee + estimate is pending or unavailable) - **Transitions**: - - "Send" → password modal → broadcast tx → **WaitTx** - - "Send" → password modal → broadcast fails → **ErrorTx** + - "Sign & Send" (correct password) → broadcast tx → **WaitTx** + - "Sign & Send" (correct password) → broadcast fails → **ErrorTx** + - "Sign & Send" (wrong password) → "Wrong password." on the password error + line, no screen change - "Back" → **Send** -#### WaitTx +#### WaitTx (`wait-tx`) - **When**: Transaction has been broadcast, waiting for on-chain confirmation. - **Elements**: @@ -429,25 +870,42 @@ transitions. - To: color dot + full address + etherscan link - Transaction hash: full hash (tap to copy) + etherscan link - Count-up timer: "Waiting for confirmation... Ns" -- **Behavior**: Polls `getTransactionReceipt` every 10 seconds. +- **Behavior**: Polls `getTransactionReceipt` every 10 seconds. The wait is + persisted: closing and reopening the popup resumes the poll, with the elapsed + counter and the timeout deadline still measured from the original broadcast. A + lookup that fails is retried on the next tick rather than counted as a missing + receipt, because a failed lookup says nothing about the transaction; but six + failures in a row (60 seconds at the poll cadence) end the wait, so an RPC + that never answers cannot leave it running indefinitely. Any lookup that + answers resets that count. - **Transitions**: - Receipt found → **SuccessTx** - - 60 seconds without confirmation → **ErrorTx** (timeout message) + - A lookup that answers "no receipt" 60 seconds or more after broadcast → + **ErrorTx** (timeout message) + - Six consecutive failed lookups → **ErrorTx**, with a message naming the + unreachable network and pointing at the RPC URL in Settings. This is a + different fact from the timeout — the chain was never asked — and says so + - Exactly one outcome: a receipt found on the tick that crosses the deadline + wins, and no outcome can be rendered over another -#### SuccessTx +#### SuccessTx (`success-tx`) - **When**: Transaction confirmed on-chain. - **Elements**: - "Transaction Confirmed" heading + - Decoded action well (shown when the transaction carried recognized + calldata; the top-level Amount and To are hidden in that case) - Amount + symbol - To: color dot + full address + etherscan link - Block number - Transaction hash: full hash (tap to copy) + etherscan link - "Done" button - **Transitions**: - - "Done" → **AddressToken** (if `selectedToken` set) or **AddressDetail** + - "Done" in the approval popup → closes the popup window + - "Done" otherwise → resets the navigation stack, then → **AddressToken** + (if `selectedToken` set) or **AddressDetail** -#### ErrorTx +#### ErrorTx (`error-tx`) - **When**: Transaction broadcast failed, or timed out waiting for confirmation. - **Elements**: @@ -459,24 +917,28 @@ transitions. full hash (tap to copy) + etherscan link - "Done" button - **Transitions**: - - "Done" → **AddressToken** (if `selectedToken` set) or **AddressDetail** + - "Done" in the approval popup → closes the popup window + - "Done" otherwise → resets the navigation stack, then → **AddressToken** + (if `selectedToken` set) or **AddressDetail** -#### Receive +#### Receive (`receive`) -- **When**: User wants to receive funds at this address. +- **When**: User wants to receive funds at this address, from Home, + AddressDetail, or AddressToken. - **Elements**: - - "Receive" heading, "Back" button + - "Back" button, "Receive" heading - Instruction text - QR code encoding the address - Full address (color dot, selectable, etherscan link) - "Copy address" button - ERC-20 warning (shown when navigating from AddressToken for non-ETH token) - **Transitions**: - - "Back" → **AddressToken** (if `selectedToken` set) or **AddressDetail** + - "Back" → previous screen (Home, AddressDetail, or AddressToken) -#### TransactionDetail +#### TransactionDetail (`transaction`) -- **When**: User tapped a transaction row from AddressDetail or AddressToken. +- **When**: User tapped a transaction row on Home, AddressDetail, or + AddressToken. - **Elements** (grouped into logical blocks using light well containers; field labels are self-explanatory so groups have no headings): - "Transaction" heading, "Back" button @@ -501,91 +963,273 @@ transitions. - Raw data (shown when calldata is present): full calldata in monospace dashed border - **Transitions**: - - "Back" → **AddressToken** (if `selectedToken` set) or **AddressDetail** + - "Back" → previous screen (Home, AddressDetail, or AddressToken) -#### AddToken +#### AddToken (`add-token`) -- **When**: User wants to track an ERC-20 token on this address. +- **When**: User wants to track an ERC-20 token, reached from "+ Token" on + AddressDetail. - **Elements**: - - "Add Token" heading, "Back" button + - "Back" button, "Add Token" heading - Instruction text (find contract address on Etherscan) - Contract address input - - Token info preview (name, symbol — fetched from contract) - - Common token quick-pick buttons + - Status line ("Looking up token...", cleared or replaced on failure) + - Common token quick-pick buttons (top 25 by market cap), which fill the + contract address input - "Add" button - **Transitions**: - - "Add" (valid contract) → **AddressDetail** - - "Back" → **AddressDetail** + - "Add" (valid contract) → tracks the token, pops the stack, and re-renders + **AddressDetail** + - "Add" with a token already tracked, a scam-listed address, or a failed + contract lookup → flash message, no screen change + - "Back" → previous screen (AddressDetail) -#### Settings +#### Settings (`settings`) -- **When**: User tapped Settings gear from Home. +- **When**: User tapped the Settings gear. - **Elements**: - - "Settings" heading, "Back" button - - Wallets: "+ Add wallet" button - - Display: "Show tracked tokens with zero balance" checkbox - - Ethereum RPC: endpoint URL input + "Save" button - - Blockscout API: endpoint URL input + "Save" button + - "Back" button, "Settings" heading + - Wallets: one row per wallet with its name (tap to rename inline), a + `[recovery phrase]` button on HD wallets only, and an `[x]` delete button, + plus a "+ Add wallet" button + - Tracked Tokens: one row per tracked token with an `[x]` remove button, + plus a "+ Add token" button + - Display: "Show tracked tokens with zero balance" checkbox, "UTC + Timestamps" checkbox, and a Theme selector (System / Light / Dark) + - Network: network selector (Ethereum Mainnet / Sepolia Testnet); switching + resets the RPC and Blockscout endpoints to that network's defaults + - Ethereum RPC: endpoint URL input + "Save" button (validated against + `eth_chainId` before being saved) + - Blockscout API: endpoint URL input + "Save" button (validated against + `/stats` before being saved) - Token Spam Protection: + - "Hide fake tokens impersonating a known symbol" checkbox - "Hide tokens with fewer than 1,000 holders" checkbox - "Hide transactions from detected fraud contracts" checkbox - - "Hide dust transactions below N gwei" checkbox + threshold input + - "Hide dust transactions below N gwei" checkbox + threshold input. The + threshold is plain decimal digits, a whole number of gwei, zero or + greater (zero hides nothing). Anything else — a fraction, a negative, + a value carrying its unit, hex (`0x10`) or exponent (`1e3`) notation — + is refused with a flash message and the field snaps back to the stored + threshold, so a number the user did not type is never stored. - Allowed Sites: list with remove buttons - Denied Sites: list with remove buttons + - About: project link, license, author, version, release date, and the + commit, which links to the commit in the repository + - Debug: hidden until revealed, then an "Enable debug mode" checkbox that + turns on the red banner and verbose logging - **Transitions**: - "+ Add wallet" → **AddWallet** - - "Back" (or Settings gear again) → **Home** + - "+ Add token" → **SettingsAddToken** + - `[recovery phrase]` on an HD wallet → **ShowRecoveryPhrase** + - `[x]` on a wallet → **DeleteWallet** + - Tap wallet name → inline rename field (no screen change) + - `[x]` on a tracked token or a site → removes it in place (no screen + change) + - Ten clicks on the version → reveals the Debug well (no screen change) + - "Back" (or Settings gear again) → previous screen (Home) -#### SiteApproval +#### ShowRecoveryPhrase (`show-phrase`) -- **When**: A website requests wallet access via `eth_requestAccounts`. Opened - in a separate popup by the background script. +- **When**: User tapped `[recovery phrase]` on a wallet row in Settings. HD + wallets only: key and xprv wallets have no recovery phrase, so their rows do + not offer the action at all. +- **Elements**: + - "Back" button, "Recovery Phrase" heading + - Wallet name + - Warning box stating that anyone holding these words can take everything in + the wallet, from any device, without the password + - Error line + - Password input + "Reveal" button, shown until the password is accepted + - The recovery phrase itself, in full and click-to-copy, shown only after a + correct password and in place of the password prompt +- **Transitions**: + - "Reveal" (correct password) → the phrase replaces the password prompt (no + screen change) + - "Reveal" (wrong password) → full-sentence error, nothing revealed (no + screen change) + - "Back" → previous screen (Settings) +- **Secret handling**: nothing is decrypted or written into the page until the + password is accepted; the phrase is never stored in state, and it is wiped + from the page whenever the screen is left by any route, including the Settings + gear. A decrypt still running when the screen is left is discarded rather than + written. The screen is not restorable, so reopening the popup lands on Home + rather than back on the phrase. + +#### DeleteWallet (`delete-wallet-confirm`) + +- **When**: User tapped the `[x]` next to a wallet in Settings. +- **Elements**: + - "Back" button, "Delete Wallet" heading + - Warning naming the wallet and stating that deletion is permanent and any + funds are unrecoverable without the recovery phrase + - Error line + - Password input + - "Confirm Delete" button +- **Transitions**: + - "Confirm Delete" (correct password, other wallets remain) → deletes the + wallet and its site permissions, then → **Settings** with a "Wallet + deleted." flash message + - "Confirm Delete" (correct password, last wallet) → deletes the wallet, + clears the selection and the navigation stack, then → **Welcome** + - Either way, the active address moves only if it belonged to the deleted + wallet, and `AUTISTMASK_ACTIVE_CHANGED` is broadcast when it does + (`src/shared/walletDelete.js`) + - "Confirm Delete" (wrong password) → "Wrong password." on the error line, + nothing deleted + - "Back" → previous screen (Settings) + +#### DeleteAddress (`delete-address-confirm`) + +- **When**: User tapped the `[x]` next to an address on Home. Offered only on HD + and xprv wallets holding more than one address: the last address of a wallet + is never removable, and a key wallet has exactly one. +- **Elements**: + - "Back" button, "Remove Address" heading + - The address's own label ("Address N") and its wallet's name + - The full address (color dot, etherscan link, tap to copy), with the ENS + name above it if resolved + - Explanation that this only stops the wallet tracking the address: nothing + is destroyed, no key is deleted, and funds stay where they are + - The route back, stated with its limit, because the obvious two are both + refused: "+" derives the next unused index (`nextIndex` is a high-water + mark), and re-importing the wallet's key material is rejected as a + duplicate by `findWalletByXpub` while the wallet is still present. What + works is deleting the whole wallet in Settings — password-gated, and it + destroys the stored secret — then importing again, whereupon + `scanForAddresses()` rediscovers the address **only if it has on-chain + activity**. An address that was never used is not found by that scan. The + text is written by `recoveryPathText()` rather than sitting in + `index.html`, so it can name the wallet's own kind of key material: an + xprv wallet has no recovery phrase to re-import. + - A warning when the address holds anything, ETH or any tracked ERC-20, + followed by the holdings themselves via `balanceLinesForAddress()` and the + USD total via `getAddressValueUsd()`. The sentence names no figure of its + own: the lines round to four decimals, so a sentence built from a rounded + number would report `0.0000 ETH` for an address holding real money. The + predicate is `addressHoldsFunds()` in `src/popup/views/helpers.js`, + unrounded and token-aware. A balance is a warning, never a refusal. + - The rule that a wallet always keeps at least one address, and that + removing the last one means deleting the wallet from Settings + - Error line + - "Remove Address" button +- **Transitions**: + - "Remove Address" → removes the address and its site permissions, then → + previous screen (Home) with an "Address removed." flash message + - "Back" → previous screen (Home), nothing removed +- **Deliberately not password-gated**, unlike DeleteWallet: a password gates the + disclosure or destruction of a secret, and this does neither. The address + stays derivable from key material the wallet still holds. +- The active address moves only if it was the address removed, and then to the + wallet's first remaining address, with `AUTISTMASK_ACTIVE_CHANGED` broadcast + so a connected site stops being told about an address the user removed + (`src/shared/walletDelete.js`). A selection in any other wallet is left alone; + one in this wallet follows the splice. +- The wallet's derivation counter (`nextIndex`) is not rewound, so "+" derives a + fresh address rather than handing back the one just removed. + +#### SettingsAddToken (`settings-addtoken`) + +- **When**: User tapped "+ Add token" in Settings. Tokens added here are tracked + across every address, unlike AddToken which is reached from one address. +- **Elements**: + - "Back" button, "Add Token" heading + - Instruction text + - "Top tokens:" quick-pick buttons (top 10 by market cap; already-tracked + tokens are disabled) + - "Or pick from top 100:" dropdown (already-tracked tokens are disabled) + + "Add selected" button + - "Or enter contract address:" input, a status line, and an "Add" button +- **Transitions**: + - Any of the three add paths, on success → adds the token and shows an + "Added SYMBOL" flash message (no screen change) + - A duplicate, a scam-listed address, or a failed contract lookup → flash + message, no screen change + - "Back" → previous screen (Settings) + +#### SiteApproval (`approve-site`) + +- **When**: A website requests wallet access via `eth_requestAccounts` or + `wallet_requestPermissions` and is on neither the allowed nor the denied list. + The background script prefers the toolbar popup (`action.openPopup()`) and + falls back to a separate popup window (`src/background/index.js`, + `requestApproval()`). - **Elements**: - "Connection Request" heading - - Site hostname (bold) + - Phishing warning banner (shown when the hostname is on the phishing + blocklist) + - Site hostname (bold) + "wants to connect to your wallet" - Address that will be shared (color dot + full address + etherscan link) - "Remember my choice for this site" checkbox - "Allow" / "Deny" buttons - **Transitions**: - - "Allow" / "Deny" → closes popup (returns result to background script) + - "Allow" / "Deny" → closes popup (returns result to background script; the + choice is persisted to the allowed or denied list when "Remember" is + checked) + - Popup closed without answering → treated as a denial -#### TxApproval +#### TxApproval (`approve-tx`) - **When**: A connected website requests a transaction via - `eth_sendTransaction`. Opened via the toolbar popup by the background script. + `eth_sendTransaction`. Always opened in a separate popup window by the + background script (`windows.create()`), because the request is triggered + programmatically rather than by a user gesture. The background populates the + transaction (nonce, gas limit, fees, chain id) against the RPC node _before_ + opening the window, so the screen shows a complete transaction and the signed + artifact can be compared with it field for field. A request that cannot be + populated — unreachable node, reverting gas estimate — opens no window and is + failed back to the site. - **Elements**: - "Transaction Request" heading + - Phishing warning banner (shown when the hostname is on the phishing + blocklist) - Site hostname (bold) + "wants to send a transaction" - Decoded action (if calldata is recognized): action name, token details, amounts, steps, deadline (see Transaction Decoding) - From: color dot + full address + etherscan link - - To/Contract: color dot + full address + etherscan link (or "contract + - Contract: color dot + full address + etherscan link (or "contract creation"), token symbol label if known - - Value: amount in ETH (4 decimal places) + - Value: amount in ETH (4 decimal places, USD in parentheses) + - Network fee (max): gas limit × fee per gas in ETH (4 decimal places, USD + in parentheses), with the gas limit and the fee per gas in gwei below it + - Network and nonce - Raw data: full calldata displayed inline (shown if present) - - Password input + - Password input and an error line - "Confirm" / "Reject" buttons - **Transitions**: - - "Confirm" (with password) → closes popup (returns result to background) + - "Confirm" (correct password) → decrypts and signs the transaction it was + shown, exactly as shown, hands the signed transaction to the background to + broadcast, then → **WaitTx** in the same popup window + - "Confirm" (wrong password) → error line, no screen change - "Reject" → closes popup (returns rejection to background) + - Popup window closed without answering → the request is rejected with + EIP-1193 code 4001 -#### SignApproval +#### SignApproval (`approve-sign`) - **When**: A connected website requests a message signature via - `personal_sign`, `eth_sign`, or `eth_signTypedData_v4`. Opened via the toolbar - popup by the background script. + `personal_sign`, `eth_sign`, or `eth_signTypedData_v4`. Opened the same way as + TxApproval, in a separate popup window. - **Elements**: - "Signature Request" heading + - Phishing warning banner (shown when the hostname is on the phishing + blocklist) - Site hostname (bold) + "wants you to sign a message" + - Danger warning box (shown for `eth_sign`, which signs a raw hash) - Type: "Personal message" or "Typed data (EIP-712)" - From: color dot + full address + etherscan link - Message: decoded UTF-8 text (personal_sign) or formatted domain/type/ message fields (EIP-712 typed data) - - Password input + - Password input and an error line - "Sign" / "Reject" buttons - **Transitions**: - - "Sign" (with password) → signs locally → closes popup (returns signature) + - "Sign" (correct password) → signs locally → closes popup (returns + signature) + - "Sign" (wrong password, or a signing failure) → error line, no screen + change - "Reject" → closes popup (returns rejection to background) + - Popup window closed without answering → the request is rejected with + EIP-1193 code 4001 ### External Services @@ -621,7 +1265,7 @@ communicates with three external services to function as a wallet: What the extension does NOT do: - No analytics or telemetry services -- No token list APIs (user adds tokens manually by contract address) +- No token list APIs (the known-token list is bundled at build time) - No Infura/Alchemy dependency (any JSON-RPC endpoint works) - No backend servers operated by the developer @@ -631,9 +1275,14 @@ CoinDesk price API, and Blockscout API), AutistMask also contacts: - **Phishing domain blocklist**: A community-maintained phishing domain blocklist is vendored into the extension at build time. At runtime, the extension fetches the live list once every 24 hours to detect newly added - domains. Only the delta (domains not already in the vendored list) is kept in - memory, keeping runtime memory usage small. The delta is persisted to - localStorage if it is under 256 KiB. + domains, plus once on a start where the list is more than 24 hours old. Only + the delta (domains not already in the vendored list) is kept in memory, + keeping runtime memory usage small. The delta and the timestamp of the fetch + that produced it are persisted to extension storage if the record is under 256 + KiB; an oversized delta is dropped along with its timestamp, so a later start + fetches again rather than claiming freshness for data it no longer holds. A + fetch that fails, or one whose delta was too large to store, is not retried + more than once an hour outside the 24-hour schedule. - **Etherscan address labels**: When confirming a transaction, the extension performs a best-effort lookup of the recipient address on Etherscan to check for phishing/scam labels. This is a direct page fetch with no API key; the @@ -676,6 +1325,36 @@ battle-tested. Exceptions require explicit authorization in a code comment referencing this policy, but as of now there are none. +### Content Security Policy + +Both manifests declare the same policy for extension pages — +`script-src 'self' 'wasm-unsafe-eval'; object-src 'self'` — as an object under +`content_security_policy.extension_pages` in `manifest/chrome.json` (MV3) and as +a bare string in `manifest/firefox.json` (MV2). + +`'wasm-unsafe-eval'` is there for one reason: libsodium. It ships a WebAssembly +build and a `wasm2js` translation of it in one file, tries WASM first, and +silently falls back to the translation if instantiation throws. Under a plain +`script-src 'self'` the fallback was taken on every popup load, announced by +nothing but an uncaught `CompileError`. Measured on the same Argon2id parameters +the vault uses (`OPSLIMIT_INTERACTIVE`, `MEMLIMIT_INTERACTIVE`), WASM derives a +key in 141-198ms and `wasm2js` in 3204-3660ms. The work factor is identical — it +is set by the ops and memory parameters, not by wall time — so the fallback +bought nothing and cost about three and a half seconds on every operation that +asks for the password, which is every signature. + +The keyword permits compiling WebAssembly and nothing else: not `eval()` of +strings, not inline script, not remote script. Using it requires already +executing script in an extension page, which is complete compromise on its own. +`'unsafe-eval'` is a different proposition and is not granted. + +The grant is pinned in both directions. `tests/manifest.test.js` asserts the +exact token set in both manifests, so dropping `'wasm-unsafe-eval'` (a silent +20x regression on the key derivation) and adding anything beyond it both fail +`make check`. `tests/vaultBackend.test.js` asserts the unit tests run the WASM +backend, and `make test-e2e` compiles a WebAssembly module inside the real popup +under the real manifest. + ### DEBUG Mode Policy The `DEBUG` constant in the popup JS enables a red "DEBUG / INSECURE" banner and @@ -740,10 +1419,12 @@ hardcoded test phrase. - Create new HD wallet (generates 12-word recovery phrase) - Import HD wallet from existing 12 or 24 word recovery phrase - Import single-address wallet from private key +- Import multi-address wallet from an extended private key (`xprv`) - Add multiple addresses within an HD wallet - Manage multiple wallets simultaneously - View ETH balance per address -- View ERC-20 token balances (user adds token by contract address) +- View ERC-20 token balances (tokens on the bundled known-token list, tokens + with 1,000 or more holders, and tokens the user adds by contract address) - Send ETH to an address - Send ERC-20 tokens to an address - Receive ETH/tokens (display address, copy to clipboard, QR code) @@ -801,14 +1482,31 @@ indexes it as a real token transfer. address. Users should always verify the full address on the confirmation screen before signing or sending. -- **Known token symbol verification**: AutistMask ships a hardcoded list of the - top 250 ERC-20 tokens with their legitimate contract addresses and symbols. - Any token transfer claiming a symbol from this list (e.g. "ETH", "USDT", - "USDC") but originating from an unrecognized contract address is identified as - a spoof and filtered from display. The fake "Ethereum" token in the attack - above used symbol "ETH" from contract - `0xD05339f9Ea5ab9d9F03B9d57F671d2abD1F55c82`, which does not match the known - WETH contract — so it would be caught by this check. +- **Known token symbol verification**: AutistMask ships a hardcoded list of + high-market-cap ERC-20 tokens with their legitimate contract addresses and + symbols. The list is a point-in-time snapshot of the highest-market-cap + Ethereum mainnet ERC-20s taken from the CoinGecko API, with decimals verified + on-chain and addresses EIP-55 checksummed; `TOKENS` in + `src/shared/tokenList.js` is the authoritative set. It is bundled at build + time and only changes when that file is regenerated. Any token transfer + claiming a symbol from this list (e.g. "ETH", "USDT", "USDC") but originating + from an unrecognized contract address is identified as a spoof and filtered + from display. The fake "Ethereum" token in the attack above used symbol "ETH" + from contract `0xD05339f9Ea5ab9d9F03B9d57F671d2abD1F55c82`, which does not + match the known WETH contract — so it would be caught by this check. Detecting + a spoof is also what adds a contract to the fraud contract blocklist below; + that is the only thing that populates it. In the transaction history the check + is the "Hide fake tokens impersonating a known symbol" setting, on by default; + with it off, spoofed transfers are shown and no new blocklist entries are + learned from them. The send-screen token selector and the balance list apply + the same check unconditionally, because they decide which tokens the user can + act on and what the user believes they own rather than what the history + displays. All three surfaces read the rule from `src/shared/symbolSpoof.js`, + so they cannot answer the question differently. A symbol the list maps to no + contract at all — `"ETH"`, the native asset, is the only one — may be borne by + no contract, so every ERC-20 claiming it is a spoof on all three. The user's + real ETH balance is not an ERC-20 and is read over RPC, so the rule never sees + it. - **Low-holder token filtering**: Token transfers from ERC-20 contracts with fewer than 1,000 holders are hidden from transaction history by default. @@ -835,13 +1533,21 @@ indexes it as a real token transfer. it. AutistMask hides transactions below a configurable dust threshold (default: 100,000 gwei / 0.0001 ETH). This is high enough to filter poisoning dust while low enough to preserve any transfer a user would plausibly care - about. The threshold is user-configurable in Settings. + about. The threshold is user-configurable in Settings; a threshold of `0` + hides nothing, exactly as clearing the checkbox does. -- **User-configurable**: All of the above filters (known symbol verification, - low-holder threshold, fraud contract blocklist, dust threshold) are settings - that default to on but can be individually disabled by the user. AutistMask is - designed as a sharp tool — users who understand the risks can configure the - wallet to show everything unfiltered, unix-style. +- **User-configurable**: All four filters (known symbol verification, low-holder + threshold, fraud contract blocklist, dust threshold) are settings that default + to on but can be individually disabled by the user. AutistMask is designed as + a sharp tool — users who understand the risks can configure the wallet to show + everything unfiltered, unix-style. All four settings govern the transaction + history; what else each one reaches varies. The known-symbol check also runs + unconditionally on the send-screen token selector and on the balance list, in + both cases identically to the history. The fraud contract blocklist is applied + unconditionally on that selector and is not consulted by the balance list at + all. The low-holder setting also gates the send selector, while the balance + list's own 1,000-holder floor is unconditional (see Data Model). The dust + threshold applies to the transaction history alone. #### Phishing Domain Protection @@ -853,6 +1559,12 @@ live list once every 24 hours and keeps only the delta (newly added domains not in the vendored list) in memory. This architecture keeps runtime memory usage small while ensuring fresh coverage of new phishing domains. +The 24-hour cadence is an alarm, not a timer; the alarm tick fetches +unconditionally rather than re-checking the 24-hour cache TTL that gates the +startup path; and the fetch timestamps live in extension storage rather than in +module variables — see [Background scheduling](#background-scheduling) for why +all three are required. + When a dApp on a blocklisted domain requests a wallet connection, transaction approval, or signature, the approval popup displays a prominent red warning banner alerting the user. The domain checker matches exact hostnames and all @@ -889,7 +1601,8 @@ Currently supported: - Built in token swaps (use a DEX in the browser) - Analytics, telemetry, or tracking of any kind - Advertisements or promotions -- Obscure token list auto-discovery (user adds tokens manually) +- Obscure token list auto-discovery — nothing outside the bundled list, the + 1,000-holder floor, and the tokens the user added by contract address - We detect common/popular ERC20s in the basic case - Fiat on/off ramps - Extensive transaction decoding/parsing @@ -905,18 +1618,18 @@ Currently supported: ### Wallet Management -- [ ] Delete wallet (with confirmation) -- [ ] Delete address from HD wallet (with confirmation) -- [ ] Show wallet's recovery phrase (requires password) +- [x] Delete wallet (with confirmation) +- [x] Delete address from HD wallet (with confirmation) +- [x] Show wallet's recovery phrase (requires password) ### Transactions -- [ ] Gas estimation and fee display before confirming +- [x] Gas estimation and fee display before confirming ### Testing -- [ ] Tests for mnemonic generation and address derivation -- [ ] Tests for xpub derivation and child address generation +- [x] Tests for mnemonic generation and address derivation +- [x] Tests for xpub derivation and child address generation - [ ] Test on Firefox (Manifest V2) ### Scam List @@ -946,14 +1659,18 @@ This repository includes data files from third-party projects that are not covered by the GPL-3.0 license above. These files, their copyright holders, and their licenses are: -| File | Source | Copyright | License | -| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | -------------------------------------------------------------- | -| `src/shared/phishingBlocklist.json` | [eth-phishing-detect](https://github.com/AugurProject/eth-phishing-detect) community-maintained phishing domain blocklist | Copyright (c) 2018 kumavis | [DBAD (Don't Be a Dick)](https://github.com/philsturgeon/dbad) | -| `src/shared/scamlist.js` (address data from MyEtherWallet) | [ethereum-lists](https://github.com/MyEtherWallet/ethereum-lists) `addresses-darklist.json` | Copyright (c) 2020 MyEtherWallet | MIT | -| `src/shared/scamlist.js` (address data from EtherScamDB) | [EtherScamDB](https://github.com/MrLuit/EtherScamDB) `scams.yaml` | Copyright (c) 2018 Luit Hollander | MIT | +| File | Source | Copyright | License | +| ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------- | -------------------------------------------------------------- | +| `src/shared/phishingBlocklist.json` | `eth-phishing-detect` community-maintained phishing domain blocklist, vendored from its `src/config.json` | Copyright (c) 2018 kumavis | [DBAD (Don't Be a Dick)](https://github.com/philsturgeon/dbad) | +| `src/shared/scamlist.js` (address data from MyEtherWallet) | [ethereum-lists](https://github.com/MyEtherWallet/ethereum-lists) `addresses-darklist.json` | Copyright (c) 2020 MyEtherWallet | MIT | +| `src/shared/scamlist.js` (address data from EtherScamDB) | [EtherScamDB](https://github.com/MrLuit/EtherScamDB) `scams.yaml` | Copyright (c) 2018 Luit Hollander | MIT | The full license texts for these third-party files are included in the -[LICENSE](LICENSE) file. +[LICENSE](LICENSE) file. The `eth-phishing-detect` row carries no repository +link because the upstream is hosted under a competitor's organization name, +which project policy keeps out of code and documentation; the vendored copy and +the runtime refresh both come from that upstream, whose URL is the +`BLOCKLIST_URL` constant in `src/shared/phishingDomains.js`. ## Author diff --git a/TODO.md b/TODO.md index b94902f..86fd8b7 100644 --- a/TODO.md +++ b/TODO.md @@ -1,34 +1,344 @@ # Workflow -- branch (from `main`) -- do the work in Next Step -- move Next Step to the top of Completed Steps -- move the top item of Future Steps into Next Step -- commit (`TODO.md` changes in the same commit as the work) -- merge to `main` if the branch is not protected, otherwise open a PR -- push +- `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. No -other branch is in flight: the settings About well landed as #145 on 2026-07-26 -and scripts-to-rule-them-all landed as #148, so the `scripts/` directory -question is resolved. Full policy file set present. `make check` verified -passing on `main` at `23aeae4` on 2026-08-09. The 1.0.0 backlog is filed as -#149-#168. +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/` with every bundle verified to have `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) now sit alongside `make check`, which +cannot see a runtime `ReferenceError` in a popup view. # Next Step -Land #149: make `DEBUG` a build-time constant that defaults to off, injected as -the `__BUILD_DEBUG__` esbuild define from `AUTISTMASK_DEBUG=1`, so a plain -`make build` stops handing every newly created wallet the publicly committed -test recovery phrase. Branch `fix/issue-149-debug-build-flag`; PR open, awaiting -review. +Land [#152](https://git.eeqj.de/sneak/AutistMask/issues/152): add ESLint to +`script/lint`. `make check` is `prettier --check` only today and cannot catch +undefined identifiers, which is how +[#150](https://git.eeqj.de/sneak/AutistMask/issues/150) and +[#151](https://git.eeqj.de/sneak/AutistMask/issues/151) shipped. # Completed Steps +- 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 `ETH` 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 @@ -52,32 +362,15 @@ review. # Future Steps -- Fix the two `ReferenceError` crashes that make whole screens unreachable: - AddToken (#150) and TransactionDetail for every ERC-20 transfer (#151). -- Add ESLint to `script/lint` (#152). `make check` is `prettier --check` only - and cannot catch undefined identifiers, which is how #150 and #151 shipped. -- Make the Firefox target functional: Chrome callback APIs are used against the - promise-only `browser` namespace (#153). -- Send and transaction-flow correctness: gas fee excluded from the - insufficient-balance check (#154), WaitTx 60s timeout overwriting a rendered - success screen (#155), last-wallet deletion leaving inconsistent state (#156). -- Security: plaintext password crossing the extension messaging boundary during - dApp approvals (#157); MV3 service worker termination killing the background - refresh and the 24h phishing list update (#158). -- Test the crypto core — `wallet.js` derivation and `vault.js` encryption (#159) - — and the address-poisoning defense in `transactions.js` (#160). -- Wallet features for 1.0: show a wallet's recovery phrase behind the password - (#161), delete an address from an HD wallet (#162). -- Docs: `docs/README.md` contradicts the code on external services and names - competitors (#163); README Screen Map omits three shipped screens (#164). -- Owner decisions: Sepolia support versus "Non-Goals for 1.0", and `isMetaMask` - naming a competitor in shipped code (#165). -- Repo policy compliance sweep: test rerun pattern, `yarn`/`npx`, frozen - lockfile, undocumented Makefile targets (#166). -- Prune the 24 stale remote feature branches (#167). -- Remove dead exports and de-duplicate copy-pasted view helpers (#168). +Only work that has no issue of its own belongs here; everything else is on the +tracker. + - Pre-1.0 security review of the extension (key handling, DEBUG mode policy, RPC - input validation) before any 1.0rc tag; #149 and #157 are parts of it, but the - review is broader than either. + input validation) before any 1.0rc tag. Individual filed issues are parts of + it, but the review is broader than any of them. +- Decide whether docker-in-docker makes `make test-e2e` and + `make test-e2e-firefox` runnable in the Gitea workflow. Extending the Chrome + suite itself is tracked as + [#183](https://git.eeqj.de/sneak/AutistMask/issues/183). - Cut 1.0.0 once the milestone is empty, then continue tagging as milestones land. diff --git a/build.js b/build.js index d738586..c5b3953 100644 --- a/build.js +++ b/build.js @@ -3,14 +3,49 @@ const path = require("path"); const { execSync } = require("child_process"); const esbuild = require("esbuild"); -const DIST_CHROME = path.join(__dirname, "dist", "chrome"); -const DIST_FIREFOX = path.join(__dirname, "dist", "firefox"); +const DIST = path.join(__dirname, "dist"); +const DIST_CHROME = path.join(DIST, "chrome"); +const DIST_FIREFOX = path.join(DIST, "firefox"); const SRC = path.join(__dirname, "src"); +// The module whose compiled DEBUG state script/verify-build asserts, and the +// manifest naming every emitted bundle that ends up containing it. The +// manifest is derived from esbuild's own dependency graph rather than from a +// hardcoded list, so it tracks the bundle layout instead of rotting with it. +const AUDITED_MODULE = "src/shared/constants.js"; +const BUNDLE_MANIFEST = path.join(DIST, "constants-bundles.txt"); + function ensureDir(dir) { fs.mkdirSync(dir, { recursive: true }); } +// Repo-relative, forward-slashed, so the manifest reads the same on every +// platform and can be consumed by a POSIX shell script without further work. +function repoRelative(p) { + return path.relative(__dirname, p).split(path.sep).join("/"); +} + +// Collect the outputs of one esbuild run that bundle AUDITED_MODULE. esbuild +// reports every input that contributed to an output in the metafile, which is +// the authoritative answer to "is constants.js in this bundle" — unlike +// searching the minified text, it does not depend on what survived minification. +// +// The ".js" filter below is the only place that assumption lives: +// script/verify-build searches every file and symlink under dist/ for a +// marker, without filtering by extension, and hard-fails if it cannot walk the +// whole tree, so a bundle emitted under some other extension fails there as +// unlisted rather than escaping both checks at once. +function outputsContainingAuditedModule(metafile) { + return Object.entries(metafile.outputs) + .filter(([outFile, info]) => { + if (!outFile.endsWith(".js")) return false; + return Object.keys(info.inputs).some( + (input) => repoRelative(input) === AUDITED_MODULE, + ); + }) + .map(([outFile]) => repoRelative(outFile)); +} + // DEBUG is a build-time flag, off unless explicitly requested. It is the only // thing that makes the hardcoded test mnemonic reachable, so the opt-in must be // exact: anything other than the literal "1" (unset, empty, "true", a typo) @@ -72,68 +107,79 @@ async function build() { __BUILD_DATE__: JSON.stringify(buildInfo.buildDate), }; + // Emitted bundles that contain constants.js, accumulated across every + // esbuild run below and written out for script/verify-build. + const auditedBundles = []; + // compile tailwind CSS console.log("Compiling Tailwind CSS..."); const tailwindInput = path.join(SRC, "popup", "styles", "main.css"); - const tailwindOutput = path.join(__dirname, "dist", "styles.css"); - ensureDir(path.join(__dirname, "dist")); + const tailwindOutput = path.join(DIST, "styles.css"); + ensureDir(DIST); + + // Drop any manifest from a previous build before emitting anything, so a + // build that never gets around to writing one cannot be verified against + // a stale list. + fs.rmSync(BUNDLE_MANIFEST, { force: true }); + // The locally installed binary, not `npx` — npx silently fetches from the + // registry when the binary is absent, which is an unpinned network fetch + // in the middle of a build. + const tailwindBin = path.join( + __dirname, + "node_modules", + ".bin", + "tailwindcss", + ); execSync( - `npx @tailwindcss/cli -i ${tailwindInput} -o ${tailwindOutput} --minify`, + `"${tailwindBin}" -i "${tailwindInput}" -o "${tailwindOutput}" --minify`, { stdio: "inherit" }, ); + // Every bundle goes through here, so metafile collection cannot be + // forgotten when a new entry point is added. + async function bundle(entryPoint, outfile) { + const result = await esbuild.build({ + entryPoints: [entryPoint], + bundle: true, + format: "iife", + outfile, + platform: "browser", + target: ["chrome110", "firefox110"], + minify: true, + metafile: true, + define, + }); + auditedBundles.push(...outputsContainingAuditedModule(result.metafile)); + } + for (const distDir of [DIST_CHROME, DIST_FIREFOX]) { ensureDir(path.join(distDir, "src", "popup")); ensureDir(path.join(distDir, "src", "background")); ensureDir(path.join(distDir, "src", "content")); // bundle popup JS with esbuild (inlines ethers, libsodium, etc.) - await esbuild.build({ - entryPoints: [path.join(SRC, "popup", "index.js")], - bundle: true, - format: "iife", - outfile: path.join(distDir, "src", "popup", "index.js"), - platform: "browser", - target: ["chrome110", "firefox110"], - minify: true, - define, - }); + await bundle( + path.join(SRC, "popup", "index.js"), + path.join(distDir, "src", "popup", "index.js"), + ); // bundle background script - await esbuild.build({ - entryPoints: [path.join(SRC, "background", "index.js")], - bundle: true, - format: "iife", - outfile: path.join(distDir, "src", "background", "index.js"), - platform: "browser", - target: ["chrome110", "firefox110"], - minify: true, - define, - }); + await bundle( + path.join(SRC, "background", "index.js"), + path.join(distDir, "src", "background", "index.js"), + ); // bundle content script - await esbuild.build({ - entryPoints: [path.join(SRC, "content", "index.js")], - bundle: true, - format: "iife", - outfile: path.join(distDir, "src", "content", "index.js"), - platform: "browser", - target: ["chrome110", "firefox110"], - minify: true, - define, - }); + await bundle( + path.join(SRC, "content", "index.js"), + path.join(distDir, "src", "content", "index.js"), + ); // bundle inpage script (injected into page context, separate file) - await esbuild.build({ - entryPoints: [path.join(SRC, "content", "inpage.js")], - bundle: true, - format: "iife", - outfile: path.join(distDir, "src", "content", "inpage.js"), - platform: "browser", - target: ["chrome110", "firefox110"], - minify: true, - define, - }); + await bundle( + path.join(SRC, "content", "inpage.js"), + path.join(distDir, "src", "content", "inpage.js"), + ); // copy popup HTML fs.copyFileSync( @@ -158,6 +204,16 @@ async function build() { path.join(DIST_FIREFOX, "manifest.json"), ); + // Written last so a build that died partway through leaves no manifest + // at all, which script/verify-build treats as a hard failure rather than + // as "nothing to check". + const manifest = [...new Set(auditedBundles)].sort(); + fs.writeFileSync(BUNDLE_MANIFEST, manifest.map((p) => `${p}\n`).join("")); + console.log( + `Bundles containing ${AUDITED_MODULE}: ${manifest.length} ` + + `(listed in ${repoRelative(BUNDLE_MANIFEST)})`, + ); + console.log("Build complete: dist/chrome/ and dist/firefox/"); } diff --git a/docs/README.md b/docs/README.md index 3476a73..98ed916 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,10 +6,10 @@ and ERC-20 tokens, and connects to web3 sites. Nothing else. ## Why AutistMask Exists -MetaMask has become bloated with swap UIs, portfolio dashboards, analytics, -tracking, and advertisements. It is no longer a simple wallet. Most alternatives -(Rabby, Rainbow, etc.) only support Chromium browsers, leaving Firefox users -without a usable option. +The most popular browser-based EVM wallet has become bloated with swap UIs, +portfolio dashboards, analytics, tracking, and advertisements. It is no longer a +simple wallet. The common alternatives only support Chromium browsers, leaving +Firefox users without a usable option. AutistMask exists because a wallet should be a wallet. You should be able to see your balances, send tokens, receive tokens, and connect to sites. That is all a @@ -27,9 +27,10 @@ analytics, use a portfolio tracker. The wallet is not the place for any of that. - **Encrypt your recovery phrase and private keys at rest.** Your secrets are encrypted on disk using Argon2id key derivation and XSalsa20-Poly1305 - authenticated encryption (via libsodium). Your password is required only when - signing a transaction. Viewing balances and addresses never requires a - password. + authenticated encryption (via libsodium). Your password is required whenever a + secret has to be decrypted: signing a transaction, signing a message or typed + data, exporting a private key, and deleting a wallet. Viewing balances and + addresses never requires a password. - **Let you choose your own RPC endpoint.** The default is a public Ethereum RPC, but you can point it at your own node or any provider you trust. No @@ -56,23 +57,25 @@ analytics, use a portfolio tracker. The wallet is not the place for any of that. - **No NFT galleries or portfolio views.** This is a wallet, not a dashboard. -- **No token auto-discovery.** AutistMask does not scan the blockchain for - tokens you might hold. You add tokens manually by contract address. This - prevents scam tokens from appearing in your wallet uninvited. +- **No third-party token list APIs.** Token balances come from the same block + explorer you configure for transaction history, and the extension ships its + own hardcoded list of top ERC-20 contract addresses for symbol-spoofing + detection. Any token you want tracked across all your addresses, you add + yourself by contract address. -- **No phishing blocklists from third parties.** AutistMask does not phone home - to check URLs against a remote blocklist. It does maintain a local list of - known scam addresses, but this is shipped with the extension, not fetched from - a server. +- **No backend servers operated by the developer.** Nothing is sent to any + server run by AutistMask. Every network destination is listed below. ## How It Works AutistMask is a browser extension that runs entirely in your browser. It does -not have a backend server. It communicates with three external services: +not have a backend server. It communicates with five external destinations: +three you configure yourself, and two fixed ones used for scam detection. ### External Services -**Ethereum JSON-RPC endpoint** (default: `ethereum-rpc.publicnode.com`) +**Ethereum JSON-RPC endpoint** (default: `ethereum-rpc.publicnode.com`; +`ethereum-sepolia-rpc.publicnode.com` on Sepolia) This is how AutistMask talks to the Ethereum network. Every wallet needs an Ethereum node to check balances, estimate gas, broadcast transactions, and @@ -80,27 +83,77 @@ verify confirmations. The default is a free public RPC endpoint. You can change this in Settings to any Ethereum JSON-RPC endpoint, including your own local node. -What gets sent: standard Ethereum JSON-RPC requests (balance queries, -transaction broadcasts, gas estimates, ENS lookups). Your addresses are -necessarily visible to the RPC provider when querying balances. +When it is contacted: on every balance refresh (every 10 seconds while the popup +is open, every 60 seconds in the background), when you type an ENS name into the +Send screen, when a send is prepared and broadcast, while a pending transaction +is polled for its receipt, and for the reverse ENS lookups used to label +addresses (cached for 12 hours). -**Blockscout API** (default: `eth.blockscout.com/api/v2`) +What gets sent: standard Ethereum JSON-RPC requests (balance queries, +transaction broadcasts, gas estimates, ENS lookups, contract-code checks). Your +addresses are necessarily visible to the RPC provider when querying balances. + +**Blockscout API** (default: `eth.blockscout.com/api/v2`; +`eth-sepolia.blockscout.com/api/v2` on Sepolia) Used to fetch token balances and transaction history. Blockscout is an open-source blockchain explorer. AutistMask queries it for your ERC-20 token -balances and recent transactions. You can change this in Settings to a +balances (including the holder counts used for spam filtering) and your recent +transactions and token transfers. You can change this in Settings to a self-hosted Blockscout instance. +When it is contacted: on every balance refresh, and whenever a screen showing +transaction history is opened. + What gets sent: your Ethereum addresses (to look up balances and transactions). **CoinDesk CADLI price API** (`data-api.coindesk.com`) -Used to fetch current USD prices for ETH and ERC-20 tokens. Prices are cached -for 5 minutes. No API key is required. No user data is sent -- only a list of -token symbols (e.g. "ETH", "USDC") to get their prices. +Used to fetch current USD prices for ETH and the top 25 tokens. Prices are +cached for 5 minutes. No API key is required. This endpoint is not +user-configurable, and it is not contacted at all while you are on a testnet, +where no USD values are shown. -What gets sent: token symbol names. No addresses, no balances, no identifying -information. +When it is contacted: while the popup is open, at most once every 5 minutes. + +What gets sent: token symbol names (e.g. "ETH", "USDC"). No addresses, no +balances, no identifying information. As with any request, CoinDesk sees your IP +address. + +**Phishing domain blocklist** (`raw.githubusercontent.com`) + +A community-maintained list of phishing domains, used to warn you when a site +that asks to connect, or to have a transaction or signature approved, is a known +scam. A copy is bundled into the extension at build time, so the protection +works before any network request happens. At runtime the extension fetches the +live list to pick up newly added domains, keeping only the entries not already +in the bundled copy (persisted locally if under 256 KiB). This endpoint is not +user-configurable. + +When it is contacted: when the background script starts, if the last fetch was +more than 24 hours ago, and every 24 hours after that. The time of the last +fetch is remembered across browser and background restarts, so restarting does +not cause a re-download. If a fetch fails, or the list is too large to keep, the +extension waits an hour before trying again outside that 24-hour schedule rather +than retrying on every restart. It is a plain download of a public file — +nothing about you is sent, but the host sees your IP address. If the fetch +fails, the bundled copy is still used. + +**Etherscan address labels** (`etherscan.io`; `sepolia.etherscan.io` on Sepolia) + +When you review a send, AutistMask fetches the recipient's public Etherscan +address page and looks for a "Fake_Phishing"/"Phish/Hack" label or a scam +warning, and shows a red warning if it finds one. This is a plain page fetch +with no API key, made by your browser. It is best-effort: if it fails, it is +silently ignored. This endpoint is not user-configurable. + +When it is contacted: each time you reach the send confirmation screen. + +What gets sent: the recipient address you are about to send to, and your IP +address. Your own addresses are not sent. + +Etherscan links shown elsewhere in the UI (on addresses, transactions, and token +contracts) are ordinary links. They contact nothing until you click them. ### What Stays Local @@ -123,8 +176,11 @@ word recovery phrase can restore your wallet on any device without your password. The password only protects the copy stored in this browser. If you lose your recovery phrase, your password cannot help you recover it. -Your password is only requested when you send a transaction. Viewing balances, -receiving funds, and browsing transaction history never require your password. +Your password is requested whenever an encrypted secret must be decrypted: when +you send a transaction, when a site asks you to sign a message or typed data, +when you export an address's private key, and when you delete a wallet. Viewing +balances, receiving funds, and browsing transaction history never require your +password. ## Installation @@ -147,34 +203,46 @@ receiving funds, and browsing transaction history never require your password. ### Creating a New Wallet 1. Click the AutistMask icon in your browser toolbar. -2. Click "Add wallet". -3. Click the die button to generate a random 12-word recovery phrase. +2. Click "Add wallet" (on first use), or open Settings and click "+ Add wallet". +3. On the "From Phrase" tab, click the die button to generate a random 12-word + recovery phrase. 4. **Write down the recovery phrase and store it safely.** Anyone with these words can take your funds. If you lose them, your wallet is gone. AutistMask cannot recover them for you. -5. Choose a password. This encrypts your recovery phrase on this device. -6. Click "Add". +5. Choose a password and confirm it. This encrypts your recovery phrase on this + device. +6. Click "Import". ### Importing an Existing Wallet -**From a recovery phrase:** Follow the same steps as creating a wallet, but -paste your existing 12 or 24 word recovery phrase instead of generating a new -one. AutistMask uses the same derivation path as MetaMask (`m/44'/60'/0'/0`), so -your addresses will match. +The Add Wallet screen has three tabs: -**From a private key:** On the Add Wallet screen, click "Have a private key -instead?" and paste your private key. This creates a single-address wallet. +**From Phrase:** Paste your existing 12 or 24 word recovery phrase instead of +generating a new one. AutistMask uses the standard BIP-44 Ethereum derivation +path (`m/44'/60'/0'/0`), which is what other wallets use by default, so your +addresses will match and your phrase stays portable in both directions. + +**From Key:** Paste a single private key. This creates a single-address wallet. + +**From xprv:** Paste an extended private key. This imports the HD wallet and +scans for used addresses. + +All three tabs ask for the same password fields, and the "Import" button +finishes the job. ### Adding More Addresses -HD wallets (created from a recovery phrase) can derive multiple addresses. On -the home screen, click the "+" button next to a wallet name to add the next -address. These are deterministic -- the same recovery phrase will always produce -the same sequence of addresses. +HD wallets (created from a recovery phrase or an xprv) can derive multiple +addresses. On the home screen, click the "+" button next to a wallet name to add +the next address. These are deterministic -- the same recovery phrase will +always produce the same sequence of addresses. ### Adding ERC-20 Tokens -AutistMask does not auto-discover tokens. To track a token: +Tokens you hold show up automatically only if they are in the extension's +bundled list of well-known tokens or have at least 1,000 holders; everything +else is treated as spam and hidden. To track a token explicitly (which also +shows it at zero balance), add it by contract address: 1. Go to an address detail view (click `[info]` on any address). 2. Click "+ Token". @@ -183,12 +251,13 @@ AutistMask does not auto-discover tokens. To track a token: 4. Click "Add". The token balance will appear on the address detail screen and on the home -screen. +screen. Tokens can also be added from Settings, under "Tracked Tokens". ## Sending 1. Click "Send" from the home screen or an address detail view. -2. Select what to send (ETH or any tracked ERC-20 token). +2. Select what to send (ETH, or any ERC-20 token with a balance on this address + that survives the spam filters). 3. Enter the recipient address or ENS name (e.g. `vitalik.eth`). 4. Enter the amount. 5. Click "Review" to see the confirmation screen. @@ -200,12 +269,18 @@ The confirmation screen shows: - **From and To addresses** with identicons and Etherscan links - **Amount** with USD estimate - **Your current balance** with USD estimate -- **Estimated network fee** in ETH with USD estimate +- **Network fee** — what the transfer is expected to cost, in ETH with a USD + estimate, and below it the larger amount reserved until it confirms. The + reserve is what the network requires up front and what the balance check gates + on; the refund of the difference is why the two differ +- **Warnings** if the recipient is a contract, a burn address, one of your own + addresses, on the bundled scam-address list, or labelled as a phisher on + Etherscan -After reviewing, click "Send" and enter your password. The transaction will be -broadcast to the network and you will see a waiting screen with a timer. Once -confirmed (or after 60 seconds), you will see either a success or error screen -with the transaction hash and an Etherscan link. +After reviewing, enter your password and click "Sign & Send". The transaction +will be broadcast to the network and you will see a waiting screen with a timer. +Once confirmed (or after 60 seconds), you will see either a success or error +screen with the transaction hash and an Etherscan link. ### Sending a Specific Token @@ -219,10 +294,10 @@ cannot accidentally switch to a different one. 1. Click "Receive" from the home screen or an address detail view. 2. Share the QR code or copy the address using the "Copy address" button. -When receiving ERC-20 tokens, make sure the sender is sending on the Ethereum -network. AutistMask is an Ethereum mainnet wallet. Tokens sent on other networks -(Polygon, Arbitrum, BSC, etc.) to the same address will not appear and may be -permanently lost. +When receiving ERC-20 tokens, make sure the sender is sending on the network you +are using. AutistMask supports Ethereum mainnet and the Sepolia testnet. Tokens +sent on other networks (Polygon, Arbitrum, BSC, etc.) to the same address will +not appear and may be permanently lost. ## Connecting to Web3 Sites @@ -236,8 +311,18 @@ pages. When a site requests access to your wallet: time. When a connected site requests a transaction, a separate approval popup appears -showing the transaction details (from, to, value, data). You must enter your -password and click "Confirm" to authorize it. +showing the transaction details (from, to, value, data, network fee, network and +nonce). Every one of those values is checked against the transaction that is +actually signed before anything is broadcast, so what you read on that screen is +what goes out or nothing does. The popup appears once the wallet has worked out +the fee and gas from the network, which takes a moment; if that fails, no popup +appears and the site is told the transaction could not be prepared. You must +enter your password and click "Confirm" to authorize it. Message and typed-data +signature requests work the same way, with a "Sign" button, and also require +your password. + +If the requesting site's domain is on the phishing blocklist, all three approval +screens show a red phishing warning before you decide. You can manage site permissions in Settings. Allowed and denied sites can be individually removed to reset their permissions. @@ -247,15 +332,25 @@ individually removed to reset their permissions. AutistMask includes several defenses against common Ethereum scams, all enabled by default: -**Known token symbol verification.** AutistMask ships a list of ~250 legitimate -ERC-20 tokens with their contract addresses. If a transaction claims to involve -a known symbol (like "ETH" or "USDT") but comes from an unrecognized contract, -it is identified as a spoof and hidden. +**Known token symbol verification.** AutistMask ships a bundled list of +high-market-cap ERC-20 tokens with their legitimate contract addresses — a +point-in-time snapshot of the highest-market-cap Ethereum mainnet ERC-20s, fixed +at build time and updated only when a new release ships a newer snapshot. If a +transaction or balance claims to involve a known symbol (like "ETH" or "USDT") +but comes from an unrecognized contract, it is identified as a spoof and hidden. +In your transaction history this is the "Hide fake tokens impersonating a known +symbol" setting, which you can switch off; doing so also stops new entries being +added to the fraud contract blocklist below, since detecting a spoof is what +fills it. The send token list always applies the check. Your balances apply it +too, with one exception: a token claiming the symbol "ETH" is not filtered +there, so a fake "ETH" token can still show up in your balance list even though +it is hidden from your transaction history and from the send token list. **Low-holder token filtering.** Tokens with fewer than 1,000 holders are hidden -from transaction history and the send token list. Legitimate tokens have -substantial holder counts; scam tokens deployed for address poisoning typically -have zero. +from transaction history and the send token list, and are left out of your +balances unless they are on the bundled known-token list or you added them +yourself. Legitimate tokens have substantial holder counts; scam tokens deployed +for address poisoning typically have zero. **Fraud contract blocklist.** When AutistMask detects a fraudulent transfer, it adds the contract address to a local blocklist. Future transactions from that @@ -266,15 +361,29 @@ ETH by default) are hidden. Scammers send dust from look-alike addresses to plant them in your transaction history. The threshold is configurable in Settings. -All of these filters can be individually disabled in Settings if you prefer to +**Scam address list.** A list of known fraud, drainer, and phishing addresses is +shipped with the extension. Sending to one of them raises a warning on the +confirmation screen. It contains only addresses involved in fraud -- it is not a +sanctions list. + +**Phishing domain warnings.** Sites asking to connect or to have something +approved are checked against the phishing domain blocklist described under +External Services, and flagged with a red banner if they match. + +The first four filters can be individually disabled in Settings if you prefer to see everything unfiltered. ## Settings Click the gear icon on the home screen to access settings: -- **Wallets**: Add a new wallet. -- **Display**: Toggle whether tracked tokens with zero balance are shown. +- **Wallets**: Your wallets, and "+ Add wallet". +- **Tracked Tokens**: The ERC-20 tokens tracked across all addresses, and "+ Add + token". +- **Display**: Toggle whether tracked tokens with zero balance are shown, switch + timestamps to UTC, and choose the theme (System, Light, or Dark). +- **Network**: Switch between Ethereum Mainnet and Sepolia Testnet. Switching + resets the RPC and Blockscout endpoints to that network's defaults. - **Ethereum RPC**: Change the Ethereum node endpoint. Default is a public RPC. You can use your own node for maximum privacy. - **Blockscout API**: Change the Blockscout instance used for token balances and @@ -282,15 +391,17 @@ Click the gear icon on the home screen to access settings: - **Token Spam Protection**: Toggle individual scam filters and set the dust transaction threshold. - **Allowed Sites / Denied Sites**: View and manage web3 site permissions. +- **About**: License, author, version, release date, and a link to the commit + this build came from. ## Frequently Asked Questions -**Is AutistMask compatible with MetaMask?** +**Can I use AutistMask alongside another wallet?** -Yes. AutistMask uses the same derivation path (`m/44'/60'/0'/0`) as MetaMask. If -you import the same recovery phrase, you will get the same addresses. You can -use both wallets side by side, though only one can be the active -`window.ethereum` provider at a time. +Yes. AutistMask uses the standard `m/44'/60'/0'/0` derivation path, so importing +the same recovery phrase gives you the same addresses as any other wallet using +that path. Two wallet extensions can be installed side by side, though only one +can be the active `window.ethereum` provider at a time. **Can I use AutistMask with a hardware wallet?** @@ -298,8 +409,9 @@ Not yet. Hardware wallet support may be added in the future. **Does AutistMask support networks other than Ethereum mainnet?** -Not currently. AutistMask is Ethereum mainnet only. Multi-chain support may be -added in the future. +Ethereum mainnet and the Sepolia testnet, selectable in Settings. No other +networks are supported today. On Sepolia, USD values are not shown, because +testnet tokens have no market value. **Where is my data stored?** @@ -312,7 +424,7 @@ to any server operated by AutistMask. Your data is deleted. Make sure you have your recovery phrase backed up before uninstalling. With your recovery phrase, you can restore your wallet in -AutistMask or any other compatible wallet (MetaMask, etc.) at any time. +AutistMask or any other wallet that uses the standard derivation path. **What happens if a transaction times out?** diff --git a/manifest/chrome.json b/manifest/chrome.json index c589225..14d1b85 100644 --- a/manifest/chrome.json +++ b/manifest/chrome.json @@ -3,8 +3,11 @@ "name": "AutistMask", "version": "0.1.0", "description": "Minimal Ethereum wallet for Chrome", - "permissions": ["storage", "activeTab"], + "permissions": ["storage", "activeTab", "alarms"], "host_permissions": [""], + "content_security_policy": { + "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'" + }, "action": { "default_popup": "src/popup/index.html" }, diff --git a/manifest/firefox.json b/manifest/firefox.json index 830f5c3..2d2fd89 100644 --- a/manifest/firefox.json +++ b/manifest/firefox.json @@ -3,7 +3,8 @@ "name": "AutistMask", "version": "0.1.0", "description": "Minimal Ethereum wallet for Firefox", - "permissions": ["storage", "activeTab", ""], + "permissions": ["storage", "activeTab", "alarms", ""], + "content_security_policy": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'", "browser_action": { "default_popup": "src/popup/index.html" }, diff --git a/package.json b/package.json index f6aea86..45a6c80 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "private": true, "scripts": { "test": "jest --forceExit", + "test:verbose": "jest --forceExit --verbose", "build": "node build.js", "lint": "prettier --check .", "fmt": "prettier --write .", @@ -16,6 +17,7 @@ "@tailwindcss/cli": "^4.2.1", "esbuild": "^0.27.3", "jest": "^30.2.0", + "playwright-core": "1.56.0", "prettier": "^3.8.1", "tailwindcss": "^4.2.1" }, diff --git a/script/check b/script/check index 3e1778c..ef90d77 100755 --- a/script/check +++ b/script/check @@ -1,12 +1,13 @@ #!/bin/sh -# script/check: run all checks (test, lint, fmt-check). Our own -# extension to scripts-to-rule-them-all. Must not modify any files. +# script/check: run all checks (test, test-verify-build, lint, fmt-check). +# Our own extension to scripts-to-rule-them-all. Must not modify any files. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" main() { "$SCRIPT_DIR/test" + "$SCRIPT_DIR/test-verify-build" "$SCRIPT_DIR/lint" "$SCRIPT_DIR/fmt-check" } diff --git a/script/test b/script/test index 498ed0d..ed0dee4 100755 --- a/script/test +++ b/script/test @@ -7,7 +7,13 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" main() { cd "$ROOT" echo "Running tests..." - timeout 30 yarn run test 2>&1 + timeout 30 yarn run test 2>&1 || { + echo "--- Rerunning with --verbose for details ---" + timeout 30 yarn run test:verbose 2>&1 || true + # Always fail: the first run already proved the tests are broken, so a + # flaky pass on the rerun must not turn the build green. + exit 1 + } } main "$@" diff --git a/script/test-e2e b/script/test-e2e new file mode 100755 index 0000000..ece9a5d --- /dev/null +++ b/script/test-e2e @@ -0,0 +1,64 @@ +#!/bin/sh +# script/test-e2e: build the extension and drive the real popup in a real +# Chromium inside a pinned container. Our own extension to +# scripts-to-rule-them-all. +# +# Deliberately NOT called by script/check or script/test: REPO_POLICIES.md +# caps make test at 20 seconds and a browser suite does not fit. Run it +# yourself before touching popup views; it is the only check that can see +# a used-but-not-imported identifier blow up at runtime. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +# mcr.microsoft.com/playwright:v1.56.0-noble, 2026-08-09 +# +# The playwright-core devDependency is pinned to the matching Playwright +# version (1.56.0) and the two must be bumped together: the browsers ship +# inside this image, and playwright-core looks for the exact browser +# revision its own version expects. A mismatch fails at launch. +IMAGE="mcr.microsoft.com/playwright@sha256:35246d87a7c88ea9b771c65d33171b2611b02a8253b4b12ce6f94376c55f99f2" + +main() { + cd "$ROOT" + + if ! command -v docker >/dev/null 2>&1; then + echo "test-e2e: docker is required to run the e2e suite" >&2 + exit 1 + fi + + echo "Building extension for e2e..." + yarn run build 2>&1 + + echo "Running e2e suite in the pinned Playwright container..." + # --ipc=host: Chromium's shared-memory needs more than the default + # 64MB /dev/shm or renderers crash. + # --user: keep files the suite touches owned by the caller, not root. + # HOME=/tmp: the mapped uid has no home directory in the image. + # PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1: without it, + # ctx.route() intercepts page requests only, and every fetch made by + # the MV3 background service worker — including the phishing + # blocklist fetch that src/background/index.js issues at worker + # startup — goes to the real internet. The flag is experimental and + # Playwright may drop or rename it. It cannot break silently: the + # harness probes service-worker interception at launch and aborts + # the whole suite if it is not in effect (see the interception + # canary in tests/e2e/harness.js). If a future Playwright removes + # the flag, that probe is what will fail, and the fix is either a + # replacement mechanism or an honest downgrade of the isolation + # claim in tests/e2e/network.js and README.md — not deleting the + # probe. The image is pinned by digest, so this can only ever bite + # on a deliberate bump. + docker run --rm \ + --ipc=host \ + --user "$(id -u):$(id -g)" \ + -e HOME=/tmp \ + -e PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1 \ + -e "E2E_TRACE_NETWORK=${E2E_TRACE_NETWORK:-0}" \ + -v "$ROOT:/work" \ + -w /work \ + "$IMAGE" \ + node tests/e2e/run.js +} + +main "$@" diff --git a/script/test-e2e-firefox b/script/test-e2e-firefox new file mode 100755 index 0000000..ebe72d6 --- /dev/null +++ b/script/test-e2e-firefox @@ -0,0 +1,63 @@ +#!/bin/sh +# script/test-e2e-firefox: build the extension and drive the real popup in +# a real Firefox inside a pinned container. The Firefox counterpart to +# script/test-e2e. Our own extension to scripts-to-rule-them-all. +# +# Deliberately NOT called by script/check or script/test, for the same +# reason as the Chrome suite: REPO_POLICIES.md caps make test at 20 seconds +# and a browser suite does not fit. +# +# Unlike script/test-e2e this builds its image locally, because no +# published image carries both a pinned Firefox and a matching geckodriver. +# All three external artifacts are pinned by digest inside the Dockerfile; +# see tests/e2e/firefox/Dockerfile. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" + +IMAGE="$("$SCRIPT_DIR/projectname")-e2e-firefox" + +main() { + cd "$ROOT" + + if ! command -v docker >/dev/null 2>&1; then + echo "test-e2e-firefox: docker is required to run the e2e suite" >&2 + exit 1 + fi + + echo "Building extension for e2e..." + yarn run build 2>&1 + + # The build context is tests/e2e/firefox/ and holds nothing but the + # Dockerfile: the harness itself arrives over the bind mount below, so + # editing it never invalidates an image layer. + echo "Building the pinned Firefox e2e image..." + docker build -t "$IMAGE" "$ROOT/tests/e2e/firefox" + + echo "Running the Firefox e2e suite..." + # --shm-size=1g: Firefox needs more than the default 64MB /dev/shm. + # --network none: the suite stubs nothing, so this is what keeps the + # run offline and deterministic. The extension swallows its own + # fetch failures, so the popup flows work unchanged; see the + # network note in README.md. Weaker than the Chrome suite's + # fixture interception, and honestly so — it proves no request + # escaped, but it cannot report which ones were attempted. + # --user: keep files the suite touches owned by the caller, not root. + # HOME=/tmp: the mapped uid has no home directory in the image. + # + # No --privileged. Firefox's sandbox logs + # "CanCreateUserNamespace() clone() failure: EPERM" on startup here; + # it is cosmetic and headless Firefox runs fine without it. + docker run --rm \ + --shm-size=1g \ + --network none \ + --user "$(id -u):$(id -g)" \ + -e HOME=/tmp \ + -v "$ROOT:/work" \ + -w /work \ + "$IMAGE" \ + node tests/e2e/firefox/run.js dist/firefox +} + +main "$@" diff --git a/script/test-verify-build b/script/test-verify-build new file mode 100755 index 0000000..a708afd --- /dev/null +++ b/script/test-verify-build @@ -0,0 +1,444 @@ +#!/bin/sh +# script/test-verify-build: exercise every failure mode of +# script/verify-build. Our own extension to scripts-to-rule-them-all, run +# from script/check so make check covers it. +# +# Why this exists: verify-build is the build-integrity guard, and three +# separate reviews of it each found a fresh vacuous pass — the grep exit-2 +# conflation, the discarded find status, the line-delimited walk. Every one +# was caught by someone building a tree by hand, because nothing in make check +# could catch it. This is that hand battery, committed and automated. +# +# Each case asserts the exit status AND a substring of the message. A guard +# that fails for the wrong reason (right status, different fault) is itself a +# defect, so matching the status alone would not be a test of anything. +# +# The fixture is a temp tree containing script/verify-build as a SYMLINK to +# the real script: verify-build takes its ROOT from dirname "$0"/.., so it +# operates on the fixture's dist/ and never reads or writes the repo's build +# output. The symlink rather than a copy is what makes a deliberate break in +# the real script fail here. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +VERIFY_BUILD="$ROOT/script/verify-build" + +MARKER_ON="autistmask-build-debug=on" +MARKER_OFF="autistmask-build-debug=off" + +NEWLINE=' +' + +PASSED=0 +FAILED=0 +SKIPPED=0 +SKIPPED_NAMES="" + +# The command prefix that runs the permission-dependent cases as a user who +# is actually subject to file permissions, and whether those cases can run at +# all. Both are decided by probe_permission_runner, never assumed. +UNPRIV="" +PERM_ENABLED=no +PERM_HOW="" + +WORK="" + +cleanup() { + [ -n "$WORK" ] || return 0 + # The cases chmod 000 files and directories on purpose. + chmod -R u+rwX "$WORK" 2>/dev/null || true + rm -rf "$WORK" +} +trap cleanup EXIT INT TERM + +WORK="$(mktemp -d "${TMPDIR:-/tmp}/autistmask-test-verify-build.XXXXXX")" +FIXTURE="$WORK/fixture" + +# verify-build mktemps its dist/ listing under TMPDIR. Pointing that inside +# our work dir keeps the run leaving no residue, and keeps it writable for the +# unprivileged user the permission cases run as. +TMPDIR="$WORK/tmp" +export TMPDIR +mkdir -p "$TMPDIR" +chmod 1777 "$TMPDIR" +chmod 755 "$WORK" + +# --- fixture --------------------------------------------------------------- + +# A stand-in for an emitted bundle: some text plus one marker literal, which +# is all verify-build reads out of the real thing. +write_bundle() { + printf 'var a=1;/* %s */\nvar b=2;\n' "$2" >"$1" +} + +# A dist/ shaped like a real build: two listed bundles under different +# browsers, an unlisted subtree to make unwalkable, and unlisted files that +# carry no marker and must not be objected to. +build_fixture() { + chmod -R u+rwX "$FIXTURE" 2>/dev/null || true + rm -rf "$FIXTURE" + + mkdir -p "$FIXTURE/script" + ln -s "$VERIFY_BUILD" "$FIXTURE/script/verify-build" + + mkdir -p "$FIXTURE/dist/chrome/src/popup" \ + "$FIXTURE/dist/chrome/src/content" \ + "$FIXTURE/dist/firefox/src/popup" + + write_bundle "$FIXTURE/dist/chrome/src/popup/index.js" "$MARKER_OFF" + write_bundle "$FIXTURE/dist/firefox/src/popup/index.js" "$MARKER_OFF" + printf 'body{color:#000}\n' >"$FIXTURE/dist/styles.css" + printf 'var c=3;\n' >"$FIXTURE/dist/chrome/src/content/content.js" + + { + echo "dist/chrome/src/popup/index.js" + echo "dist/firefox/src/popup/index.js" + } >"$FIXTURE/dist/constants-bundles.txt" + + # Readable and traversable by the unprivileged user the permission cases + # run as, before those cases take that away again on purpose. + chmod -R a+rX "$FIXTURE" +} + +# --- permission runner ------------------------------------------------------ + +# Run a command through the current unprivileged runner. Unquoted on purpose: +# UNPRIV is a command prefix that has to word-split. +run_unpriv() { + # shellcheck disable=SC2086 + $UNPRIV "$@" +} + +# Decide whether the permission-dependent cases can run, and prove it rather +# than assuming it. +# +# The problem: the CI image declares no USER, so CI runs as root, and root is +# not subject to file permissions — chmod 000 stops neither find nor grep. A +# permission case run as root passes vacuously, which is worse than no case at +# all because it reads as coverage. +# +# So the runner is validated with two probes before any permission case is +# counted: +# +# - a mode-644 file MUST be readable through it. If not, the runner itself +# is broken (missing helper, no such user, sandbox), and every case run +# through it would fail for the wrong reason. +# - a mode-000 file MUST NOT be readable through it. If it is, permissions +# are not in force and the cases would pass without proving anything. +# +# Unprivileged: the runner is empty and both probes are about this process, +# which is the honest answer. Root: setpriv and runuser are tried, both +# present in the pinned CI base image. Only when no candidate passes both +# probes are the cases skipped, and a skipped run says so unmistakably. +probe_permission_runner() { + _probe="$WORK/probe" + mkdir -p "$_probe" + printf 'readable\n' >"$_probe/public" + printf 'secret\n' >"$_probe/private" + chmod 755 "$_probe" + chmod 644 "$_probe/public" + chmod 000 "$_probe/private" + + if [ "$(id -u)" -eq 0 ]; then + _candidates="setpriv|setpriv --reuid=65534 --regid=65534 --clear-groups -- +runuser|runuser -u nobody --" + else + _candidates="direct|" + fi + + _tried="" + _saved_ifs="$IFS" + IFS="$NEWLINE" + for _line in $_candidates; do + IFS="$_saved_ifs" + _label="${_line%%|*}" + _cmd="${_line#*|}" + _tried="${_tried:+$_tried, }$_label" + + if [ -n "$_cmd" ]; then + _bin="${_cmd%% *}" + command -v "$_bin" >/dev/null 2>&1 || continue + fi + + UNPRIV="$_cmd" + # Broken or unusable runner: the cases would fail for the wrong + # reason. Reaching the script under test is part of usable. + run_unpriv cat "$_probe/public" >/dev/null 2>&1 || continue + run_unpriv cat "$VERIFY_BUILD" >/dev/null 2>&1 || continue + # Permissions not in force through this runner: the cases would pass + # without testing anything. + if run_unpriv cat "$_probe/private" >/dev/null 2>&1; then + continue + fi + + PERM_ENABLED=yes + PERM_HOW="$_label" + IFS="$_saved_ifs" + return 0 + done + IFS="$_saved_ifs" + + UNPRIV="" + PERM_ENABLED=no + PERM_HOW="$_tried" +} + +# --- case runner ------------------------------------------------------------ + +# check_case +# +# Rebuilds the fixture, applies inside it, runs verify-build, and +# requires both the exit status and the message. marks a case that only +# means anything when file permissions are in force. +check_case() { + _name="$1" + _perm="$2" + _mode="$3" + _want_status="$4" + _want_text="$5" + _setup="$6" + + if [ "$_perm" = yes ] && [ "$PERM_ENABLED" != yes ]; then + SKIPPED=$((SKIPPED + 1)) + SKIPPED_NAMES="$SKIPPED_NAMES## - $_name$NEWLINE" + echo " SKIP (permissions not in force): $_name" + return 0 + fi + + build_fixture + if ! (cd "$FIXTURE" && "$_setup") >/dev/null 2>&1; then + FAILED=$((FAILED + 1)) + echo " FAIL: $_name" + echo " the case's own setup failed, so nothing was tested." + return 0 + fi + + if [ "$_mode" = debug ]; then + _debug=1 + else + _debug="" + fi + + # Exported rather than set as a command prefix: run_unpriv is a function, + # and an assignment prefixed to a function call is not portable. + AUTISTMASK_DEBUG="$_debug" + export AUTISTMASK_DEBUG + + _status=0 + if [ "$_perm" = yes ]; then + _out="$(run_unpriv "$FIXTURE/script/verify-build" 2>&1)" || _status=$? + else + _out="$("$FIXTURE/script/verify-build" 2>&1)" || _status=$? + fi + + _ok=yes + _why="" + + if [ "$_status" -ne "$_want_status" ]; then + _ok=no + _why="exit status $_status, wanted $_want_status" + fi + + # Same discipline verify-build itself applies to grep: 0 and 1 are + # answers, anything else is not, and must not be read as "no match". + _g=0 + printf '%s\n' "$_out" | grep -q -F -e "$_want_text" || _g=$? + case "$_g" in + 0) ;; + 1) + _ok=no + _why="${_why:+$_why; }message did not contain: $_want_text" + ;; + *) + _ok=no + _why="${_why:+$_why; }grep exited $_g matching the message, so the + message was never checked" + ;; + esac + + if [ "$_ok" = yes ]; then + PASSED=$((PASSED + 1)) + echo " ok: $_name" + return 0 + fi + + FAILED=$((FAILED + 1)) + echo " FAIL: $_name" + echo " $_why" + echo " --- verify-build output ---" + printf '%s\n' "$_out" | sed 's/^/ /' + echo " --- end output ---" +} + +# --- cases ------------------------------------------------------------------ +# +# Each runs with the fixture as its working directory. + +c_control() { :; } + +c_trailing_space() { + cp dist/chrome/src/popup/index.js "dist/chrome/src/popup/index.js " +} + +c_embedded_newline() { + cp dist/chrome/src/popup/index.js "dist/chrome/src/popup/index.js$NEWLINE" +} + +c_dist_symlink() { + mv dist dist.real + ln -s dist.real dist +} + +c_unwalkable_subtree() { chmod 000 dist/chrome/src/content; } + +c_dangling_symlink() { + ln -s /nonexistent-target-for-test-verify-build dist/chrome/dangling.js +} + +c_dir_symlink() { ln -s src dist/chrome/link-to-dir; } + +c_alias_symlink() { ln -s popup/index.js dist/chrome/src/aliased.js; } + +c_manifest_missing() { rm dist/constants-bundles.txt; } + +c_manifest_empty() { : >dist/constants-bundles.txt; } + +c_manifest_unreadable() { chmod 000 dist/constants-bundles.txt; } + +c_bundle_missing() { rm dist/chrome/src/popup/index.js; } + +c_bundle_empty() { : >dist/chrome/src/popup/index.js; } + +c_bundle_unreadable() { chmod 000 dist/chrome/src/popup/index.js; } + +c_unlisted_extension() { + cp dist/chrome/src/popup/index.js dist/chrome/src/popup/extra.mjs +} + +c_no_marker() { printf 'var d=4;\n' >dist/chrome/src/popup/index.js; } + +c_both_markers() { + printf '/* %s */\n' "$MARKER_ON" >>dist/chrome/src/popup/index.js +} + +run_cases() { + check_case "control: untouched dist passes" \ + no release 0 "2 bundle(s) verified $MARKER_OFF" c_control + + check_case "unlisted marker-carrying file, trailing space in name" \ + no release 1 "carries a debug marker but is absent from" \ + c_trailing_space + + check_case "unlisted marker-carrying file, newline in name" \ + no release 1 "carries a debug marker but is absent from" \ + c_embedded_newline + + check_case "dist/ replaced by a symlink" \ + no release 1 "dist is a symlink, not a directory." c_dist_symlink + + check_case "unwalkable subtree under dist/" \ + yes release 1 "enumerating dist/, so part of the tree" \ + c_unwalkable_subtree + + check_case "dangling symlink under dist/" \ + no release 1 \ + "reading dist/chrome/dangling.js, so the file could not be" \ + c_dangling_symlink + + check_case "symlink to a directory under dist/" \ + no release 1 \ + "reading dist/chrome/link-to-dir, so the file could not be" \ + c_dir_symlink + + check_case "symlink to a listed bundle under an unlisted path" \ + no release 1 \ + "dist/chrome/src/aliased.js carries a debug marker but is absent" \ + c_alias_symlink + + check_case "manifest missing" \ + no release 1 "dist/constants-bundles.txt is missing." \ + c_manifest_missing + + check_case "manifest empty" \ + no release 1 "is empty, so no emitted bundle was found to contain" \ + c_manifest_empty + + check_case "manifest unreadable" \ + yes release 1 "is not readable, so nothing was inspected." \ + c_manifest_unreadable + + check_case "listed bundle missing" \ + no release 1 \ + "lists dist/chrome/src/popup/index.js, which does not exist." \ + c_bundle_missing + + check_case "listed bundle empty" \ + no release 1 "which is empty. An empty bundle" c_bundle_empty + + check_case "listed bundle unreadable" \ + yes release 1 \ + "reading dist/chrome/src/popup/index.js, so the file could not be" \ + c_bundle_unreadable + + check_case "unlisted extension carrying a marker" \ + no release 1 \ + "dist/chrome/src/popup/extra.mjs carries a debug marker but is" \ + c_unlisted_extension + + check_case "listed bundle carries no marker" \ + no release 1 "carries no debug marker, so its DEBUG state cannot be" \ + c_no_marker + + check_case "listed bundle carries both markers" \ + no release 1 "carries both debug markers, so DEBUG was not resolved" \ + c_both_markers + + check_case "wrong marker for the requested mode" \ + no debug 1 "is $MARKER_OFF but this build expects $MARKER_ON" \ + c_control +} + +# --- main -------------------------------------------------------------------- + +main() { + cd "$ROOT" + + [ -x "$VERIFY_BUILD" ] || { + echo "test-verify-build: $VERIFY_BUILD is missing or not executable" >&2 + exit 1 + } + + echo "Testing script/verify-build failure modes..." + probe_permission_runner + if [ "$PERM_ENABLED" = yes ]; then + echo " permission cases: enabled (runner: $PERM_HOW, proved against" \ + "a mode-000 file)" + fi + + run_cases + + if [ "$FAILED" -ne 0 ]; then + echo "test-verify-build: $FAILED case(s) FAILED," \ + "$PASSED passed, $SKIPPED skipped" >&2 + exit 1 + fi + + if [ "$SKIPPED" -ne 0 ]; then + cat <&2 + exit 1 +} + +cleanup() { + [ -z "$LISTING" ] || rm -f "$LISTING" +} +trap cleanup EXIT + +# Is the literal $1 present in the file $2? Match (grep exit 0) and no-match +# (exit 1) are answers about the emitted output. Anything else (exit 2: the +# file could not be read) is not an answer at all, and must not be reported as +# "no marker" — that would blame the bundle for a permissions or I/O fault. +has_marker() { + _hm_status=0 + grep -q -F -e "$1" -- "$2" || _hm_status=$? + case "$_hm_status" in + 0) return 0 ;; + 1) return 1 ;; + *) + fail "grep exited $_hm_status reading $2, so the file could not be + searched and its DEBUG state was not checked at all. That is a permissions + or I/O fault on the artifact, not a change in the emitted output. Refusing + to report success." + ;; + esac +} + +# Does the manifest list the path $1, as a whole line? Same discipline as +# has_marker: exit 0 and 1 are answers about the manifest, exit 2 means the +# manifest could not be read and is not an answer at all. Without this, an +# unreadable manifest reads as "this file is not listed" and every emitted +# bundle gets reported as an unlisted one. +# +# A path containing a newline is answered without asking grep, because grep +# would read the pattern as two patterns and report a match on either. That is +# how such a path escaped this check even once the walk stopped splitting it: +# the half before the newline matched a listed line and the file was skipped. +# The manifest is line-delimited, so it cannot name such a path at all, and +# "not listed" is the only true answer. +is_listed() { + case "$1" in + *"$NEWLINE"*) return 1 ;; + esac + _il_status=0 + grep -q -x -F -e "$1" -- "$MANIFEST" || _il_status=$? + case "$_il_status" in + 0) return 0 ;; + 1) return 1 ;; + *) + fail "grep exited $_il_status reading $MANIFEST, so it could not be + searched and nothing was established about which bundles it lists. That is + a permissions or I/O fault on the manifest, not a stale manifest. Refusing + to report success." + ;; + esac +} + +# Read one bundle's DEBUG state into MARKER. Exactly one marker must be +# present. Both means the ternary in constants.js was never folded, which is +# what happens when the __BUILD_DEBUG__ define goes missing from build.js: +# DEBUG stops being known at build time. Neither means we are reading output +# we do not understand. Both are hard failures; neither is ever treated as +# absence of a problem. +read_marker() { + _file="$1" + _on=no + _off=no + if has_marker "$MARKER_ON" "$_file"; then _on=yes; fi + if has_marker "$MARKER_OFF" "$_file"; then _off=yes; fi + + if [ "$_on" = yes ] && [ "$_off" = yes ]; then + fail "$_file carries both debug markers, so DEBUG was not resolved at + build time: the ternary in src/shared/constants.js survived into the + emitted output. This does not mean the debug branch is live in this + artifact: an unresolved __BUILD_DEBUG__ is undeclared in extension + context, so DEBUG evaluates to false at runtime. It does mean the + release/debug distinction is no longer enforced at build time, and which + way that fallback happens to evaluate is then an accident a refactor can + flip. Check that build.js still defines __BUILD_DEBUG__." + fi + if [ "$_on" = no ] && [ "$_off" = no ]; then + fail "$_file carries no debug marker, so its DEBUG state cannot be + determined. Either BUILD_DEBUG_MARKER is gone from src/shared/constants.js + or the emitted output changed shape. Refusing to report success." + fi + + if [ "$_on" = yes ]; then + MARKER="$MARKER_ON" + else + MARKER="$MARKER_OFF" + fi +} + +# The manifest says which bundles must carry a marker. This says no other +# emitted file may carry one, which catches a manifest that has gone stale +# or short rather than trusting whatever it happens to list. +# +# Deliberately unfiltered by extension. build.js selects manifest entries with +# an endsWith(".js") test; repeating that literal here would mean a bundle +# emitted under some other extension escaped the manifest AND this check at +# once, which is the correlated blind spot the two-source design exists to +# avoid. Every regular file and every symlink under dist/ is searched — that +# is the whole of what a build emits — so build.js's filter is the only place +# the assumption lives and this check is what catches it being wrong. +# +# That claim only holds if the walk is exhaustive and every name survives it +# intact, so four things are enforced here rather than assumed: +# +# - the walk is NUL-delimited and the paths reach the check as arguments, so +# no name can be reshaped on the way in. Read line by line, a name with a +# trailing space lost it to read's field splitting and the remnant then +# matched a manifest line, and a name containing a newline arrived as a +# listed path plus an empty one. Both left a marker-carrying, unlisted file +# unchecked while the script still reported success. Delivering such a name +# intact is only half of it; is_listed also has to keep it out of grep's +# pattern, for the same reason. +# - find's exit status is checked. A subtree it cannot descend is reported on +# stderr and then simply missing from the listing, so an unchecked status +# turns "could not look" into "nothing was there" — the same conflation +# has_marker exists to prevent. The status cannot be read off a pipeline, +# so the listing lands in a file that xargs then reads back. +# - symlinks are walked too (-type l), not skipped. A marker-carrying bundle +# reachable under an unlisted path in dist/ is a stale manifest whether the +# path is a link or a file, and grep reads through the link. A link that +# cannot be read through — dangling, or pointing at a directory — fails +# hard via has_marker's exit-2 path, which is the fail-closed answer: the +# build emits neither, so their DEBUG state is unproven, not fine. +# - dist/ itself must be a directory and not a symlink, which main asserts +# before anything reads through it. find does not follow a symlink named on +# its own command line, so a linked dist/ collapses this walk to one entry +# and cross-checks nothing. +# +# Types other than regular files and symlinks are left out on purpose: a build +# emits none of them, and grep on a fifo would hang rather than fail. +check_unlisted_bundles() { + LISTING="$(mktemp "${TMPDIR:-/tmp}/verify-build-dist.XXXXXX")" || + fail "could not create a temporary file for the dist/ listing, so the + tree was never walked. Refusing to report success." + + _find_status=0 + find dist \( -type f -o -type l \) -print0 >"$LISTING" || _find_status=$? + [ "$_find_status" -eq 0 ] || + fail "find exited $_find_status enumerating dist/, so part of the tree + was never walked and nothing was established about the files in it. Any + unlisted bundle there went unchecked. That is a permissions or I/O fault on + the artifact, not a stale manifest. Refusing to report success." + + _scan_status=0 + xargs -0 "$SELF" "$SCAN_FLAG" <"$LISTING" || _scan_status=$? + [ "$_scan_status" -eq 0 ] || + fail "the unlisted-bundle scan exited $_scan_status: either a path + under dist/ failed the check reported above, or the scan could not be run + at all. Refusing to report success." +} + +# The per-path half of check_unlisted_bundles. It runs in a re-invocation of +# this script, so it uses the same is_listed and has_marker as the rest of the +# file rather than a second copy of them that could drift. Paths arrive as +# arguments and are never split, joined or trimmed. +scan_dist_paths() { + for _file in "$@"; do + if is_listed "$_file"; then + continue + fi + if has_marker "$MARKER_ON" "$_file" || + has_marker "$MARKER_OFF" "$_file"; then + fail "$_file carries a debug marker but is absent from $MANIFEST, + so the manifest no longer describes the emitted bundles." + fi + done +} + +# The requested mode, read from our own environment using build.js's exact +# rule: only the literal 1 opts in. Deliberately not taken from anything +# build.js records about itself, so build.js cannot vouch for build.js. +expected_marker() { + if [ "${AUTISTMASK_DEBUG-}" = "1" ]; then + echo "$MARKER_ON" + else + echo "$MARKER_OFF" + fi +} + +main() { + cd "$ROOT" + + # Internal re-entry from check_unlisted_bundles' xargs. Not part of the + # command-line interface: nothing else invokes it, and it is a distinct + # entry point rather than a mode flag threaded through the checks below. + if [ "${1-}" = "$SCAN_FLAG" ]; then + shift + scan_dist_paths "$@" + return 0 + fi + + expected="$(expected_marker)" + echo "Verifying emitted bundles (expecting $expected)..." + + # Asserted here rather than left to grep. A symlinked dist/ used to fail + # only because GNU grep exits 2 on a directory, so check_unlisted_bundles' + # single entry hit has_marker's I/O path by luck; under a grep that exits 1 + # instead, the whole cross-check would have collapsed into a pass. + if [ -h dist ]; then + fail "dist is a symlink, not a directory. find does not follow a + symlink named on its own command line, so the unlisted-bundle cross-check + would see one entry instead of the emitted tree and establish nothing about + it. Refusing to report success." + fi + [ -d dist ] || + fail "dist is not a directory, so there is no emitted tree to verify. + build.js writes it; run make build first." + + [ -f "$MANIFEST" ] || + fail "$MANIFEST is missing. build.js writes it at the end of a + successful build; run make build first." + [ -s "$MANIFEST" ] || + fail "$MANIFEST is empty, so no emitted bundle was found to contain + src/shared/constants.js. That is never correct, so it is a failure and not + a pass." + [ -r "$MANIFEST" ] || + fail "$MANIFEST is not readable, so nothing was inspected. That is a + permissions or I/O fault, not a pass." + + count=0 + while read -r file; do + [ -n "$file" ] || continue + [ -f "$file" ] || + fail "$MANIFEST lists $file, which does not exist." + [ -s "$file" ] || + fail "$MANIFEST lists $file, which is empty. An empty bundle + carries no marker and proves nothing, so this is a failure and not a pass." + read_marker "$file" + [ "$MARKER" = "$expected" ] || + fail "$file is $MARKER but this build expects $expected." + echo " ok: $file ($MARKER)" + count=$((count + 1)) + done <"$MANIFEST" + + [ "$count" -gt 0 ] || fail "no bundles were inspected." + + check_unlisted_bundles + + echo "verify-build: $count bundle(s) verified $expected" +} + +main "$@" diff --git a/src/background/index.js b/src/background/index.js index 9a4879f..f89e386 100644 --- a/src/background/index.js +++ b/src/background/index.js @@ -5,7 +5,6 @@ const { DEFAULT_RPC_URL } = require("../shared/constants"); const { SUPPORTED_CHAIN_IDS, networkByChainId } = require("../shared/networks"); const { onChainSwitch } = require("../shared/chainSwitch"); -const { getBytes } = require("ethers"); const { state, loadState, @@ -13,14 +12,32 @@ const { currentNetwork, } = require("../shared/state"); const { refreshBalances, getProvider } = require("../shared/balances"); -const { debugFetch } = require("../shared/log"); -const { decryptWithPassword } = require("../shared/vault"); -const { getSignerForAddress } = require("../shared/wallet"); +const { debugFetch, log } = require("../shared/log"); +const { + verifySignedTx, + verifySignature, + failureIsRetryable, + describeTxFailure, + sameAddress, + ApprovalMismatchError, + TX_STAGE_SIGN, + TX_STAGE_VERIFY, + TX_STAGE_BROADCAST, + TX_STAGE_INFLIGHT, +} = require("../shared/approvalVerify"); +const { prepareApprovalTx } = require("../shared/approvalTx"); const { isPhishingDomain, - updatePhishingList, - startPeriodicRefresh, + refreshPhishingListOnSchedule, + initPhishingList, } = require("../shared/phishingDomains"); +const { + BALANCE_REFRESH_ALARM, + PHISHING_REFRESH_ALARM, + BALANCE_REFRESH_PERIOD_MINUTES, + ensureRecurringAlarms, + registerAlarmHandlers, +} = require("../shared/alarms"); const storageApi = typeof browser !== "undefined" @@ -63,6 +80,14 @@ async function getActiveAddress() { return null; } +// Whether a request names a signing address other than the active one. Such a +// request is refused rather than quietly signed as whichever address happens +// to be active: the page asked for account A and would otherwise be handed +// something from account B. +function namesAnotherAddress(requested, activeAddress) { + return !!requested && !sameAddress(requested, activeAddress); +} + async function getRpcUrl() { const s = await getState(); return s.rpcUrl || DEFAULT_RPC_URL; @@ -102,6 +127,55 @@ function resetPopupUrl() { } } +// Settle a pending approval: hand `result` to the promise the requesting page +// is waiting on and retire the approval. This is the ONLY place an approval is +// resolved or removed — the popup closing, an active-address switch, a reject +// from the popup and the attempt that signs and broadcasts all come through +// here — because a settlement that bypasses the claim below is a fund-loss bug +// and enumerating the call sites has repeatedly missed one. +// +// A claimed approval belongs to the attempt holding the claim, and only that +// attempt may settle it. Anything else settling first would leave the attempt +// running to completion against an already-settled promise: the transaction +// reaches the chain while the page is told "User rejected the request", and the +// user's natural response is to send it again at a fresh nonce. +// +// Returns false when the approval is gone or claimed by someone else, so the +// caller can refuse instead of assuming it settled. +function settleApproval(id, result, options) { + const approval = pendingApprovals[id]; + if (!approval) return false; + const holdsClaim = !!(options && options.holdsClaim); + if (approval.attemptInFlight && !holdsClaim) return false; + delete pendingApprovals[id]; + approval.resolve(result); + resetPopupUrl(); + return true; +} + +// Take exclusive hold of a pending approval for one attempt, or refuse. +// +// An approval that failed retryably has to stay in pendingApprovals, so its +// presence cannot be the interlock against a second attempt; this flag is. It +// is set synchronously, before the handler's first await, so a second response +// carrying the same id — a reloaded approval window re-rendering a live +// Approve button, a popup that emits the message twice — finds the attempt +// already running instead of starting an independent verify and broadcast. +// Without it one approval can put two transactions on the chain: with the +// ordinary dApp approval shape the page fixes no nonce, so two artifacts +// signed at different nonces both verify. +function claimApproval(approval) { + if (approval.attemptInFlight) return false; + approval.attemptInFlight = true; + return true; +} + +// Release an approval whose attempt failed in a way the user can retry. +// Nothing was broadcast, so the next attempt may claim it. +function releaseApproval(approval) { + approval.attemptInFlight = false; +} + // Open approval in a separate popup window. // This is the primary mechanism for tx/sign approvals (triggered programmatically, // not from a user gesture) and the fallback for site-connection approvals. @@ -162,13 +236,21 @@ function requestApproval(origin, hostname) { // Uses windows.create() directly because tx approvals are triggered programmatically // (from a dApp RPC call), not from a user gesture, so action.openPopup() is // unreliable in this context. -function requestTxApproval(origin, hostname, txParams) { +// +// `approvedTx` is the fully populated transaction (see approvalTx.js): the +// object the popup displays, the object it signs, and the object the artifact +// is verified against. `approvedFrom` is the address that is active now, and +// it is pinned here rather than read again at signing time — an address switch +// between approval and signing must refuse, not sign from an account this +// screen never named. +function requestTxApproval(origin, hostname, approvedTx, approvedFrom) { return new Promise((resolve) => { const id = crypto.randomUUID(); pendingApprovals[id] = { origin, hostname, - txParams, + approvedTx, + approvedFrom, resolve, type: "tx", }; @@ -181,13 +263,14 @@ function requestTxApproval(origin, hostname, txParams) { // Uses windows.create() directly because sign approvals are triggered programmatically // (from a dApp RPC call), not from a user gesture, so action.openPopup() is // unreliable in this context. -function requestSignApproval(origin, hostname, signParams) { +function requestSignApproval(origin, hostname, signParams, approvedFrom) { return new Promise((resolve) => { const id = crypto.randomUUID(); pendingApprovals[id] = { origin, hostname, signParams, + approvedFrom, resolve, type: "sign", }; @@ -210,8 +293,7 @@ runtime.onConnect.addListener((port) => { // Keep pending — user can reopen the toolbar popup return; } - approval.resolve({ approved: false, remember: false }); - delete pendingApprovals[id]; + settleApproval(id, { approved: false, remember: false }); } resetPopupUrl(); }); @@ -440,6 +522,16 @@ async function handleRpc(method, params, origin) { ? { method, message: params[0], from: params[1] } : { method, message: params[1], from: params[0] }; + if (namesAnotherAddress(signParams.from, activeAddress)) { + return { + error: { + code: 4100, + message: + "This site asked to sign as an address that is not the active one.", + }, + }; + } + if (method === "eth_sign") { signParams.dangerWarning = "\u26a0\ufe0f DANGER: This site is requesting to sign a raw hash. " + @@ -451,6 +543,7 @@ async function handleRpc(method, params, origin) { origin, hostname, signParams, + activeAddress, ); if (decision.error) return { error: decision.error }; return { result: decision.signature }; @@ -472,10 +565,20 @@ async function handleRpc(method, params, origin) { } const signParams = { method, typedData: params[1], from: params[0] }; + if (namesAnotherAddress(signParams.from, activeAddress)) { + return { + error: { + code: 4100, + message: + "This site asked to sign as an address that is not the active one.", + }, + }; + } const decision = await requestSignApproval( origin, hostname, signParams, + activeAddress, ); if (decision.error) return { error: decision.error }; return { result: decision.signature }; @@ -497,7 +600,51 @@ async function handleRpc(method, params, origin) { } const txParams = params?.[0] || {}; - const decision = await requestTxApproval(origin, hostname, txParams); + if (namesAnotherAddress(txParams.from, activeAddress)) { + return { + error: { + code: 4100, + message: + "This site asked to send from an address that is not the active one.", + }, + }; + } + + // Populate here, before any window opens, so that the transaction the + // user is shown is a complete one and is the same object the signed + // artifact is checked against. A failure raises no approval at all and + // is reported to the requesting page; see approvalTx.js. + let approvedTx; + try { + approvedTx = await prepareApprovalTx( + getProvider(await getRpcUrl()), + activeAddress, + txParams, + ); + } catch (e) { + return { error: { message: e.message } }; + } + + // Population is a network round trip, and the user can switch address + // during it. Raising the approval anyway would put an account on the + // screen that the wallet is no longer on, and it could never be signed + // — the signing handler refuses exactly that. Refuse it here instead, + // while the page is still waiting and nothing has been displayed. + if (!sameAddress(await getActiveAddress(), activeAddress)) { + return { + error: { + message: + "The active address changed while this transaction was being prepared, so it was not sent.", + }, + }; + } + + const decision = await requestTxApproval( + origin, + hostname, + approvedTx, + activeAddress, + ); if (decision.error) return { error: decision.error }; return { result: decision.txHash }; } @@ -542,15 +689,21 @@ async function broadcastAccountsChanged() { for (const key of Object.keys(connectedSites)) { delete connectedSites[key]; } - // Reject and close any pending approval popups so they don't hang + // Reject and close any pending approval popups so they don't hang. An + // approval an attempt has already claimed is left alone entirely: it is + // being signed and broadcast right now, and neither rejecting it to the + // page nor closing the window it is reporting into is survivable. for (const [id, approval] of Object.entries(pendingApprovals)) { - if (approval.type === "tx" || approval.type === "sign") { - approval.resolve({ - error: { code: 4001, message: "User rejected the request." }, - }); - } else { - approval.resolve({ approved: false, remember: false }); - } + const rejection = + approval.type === "tx" || approval.type === "sign" + ? { + error: { + code: 4001, + message: "User rejected the request.", + }, + } + : { approved: false, remember: false }; + if (!settleApproval(id, rejection)) continue; if (approval.windowId) { windowsApi.remove(approval.windowId, () => { if (runtime.lastError) { @@ -558,7 +711,6 @@ async function broadcastAccountsChanged() { } }); } - delete pendingApprovals[id]; } resetPopupUrl(); const s = await getState(); @@ -593,12 +745,22 @@ async function broadcastAccountsChanged() { // Background balance refresh: every 60 seconds when the popup isn't open. // When the popup IS open, its 10-second interval keeps lastBalanceRefresh // fresh, so this naturally skips. -const BACKGROUND_REFRESH_INTERVAL = 60000; +// +// The alarm period alone sets the cadence; this guard only suppresses a +// refresh something else has just done, so it must stay strictly shorter than +// the period. Timed to the period it would veto every tick it gates — +// lastBalanceRefresh is stamped after the refresh runs, so a tick one period +// after the last one always lands inside a guard of equal length and the real +// cadence becomes two periods. Half the period keeps it comfortably above the +// popup's 10-second refresh, so an open popup still suppresses the background +// job, and comfortably below the alarm period, so the schedule always wins. +const BALANCE_REFRESH_PERIOD_MS = BALANCE_REFRESH_PERIOD_MINUTES * 60 * 1000; +const RECENT_BALANCE_REFRESH_MS = Math.floor(BALANCE_REFRESH_PERIOD_MS / 2); async function backgroundRefresh() { await loadState(); const now = Date.now(); - if (now - (state.lastBalanceRefresh || 0) < BACKGROUND_REFRESH_INTERVAL) + if (now - (state.lastBalanceRefresh || 0) < RECENT_BALANCE_REFRESH_MS) return; if (state.wallets.length === 0) return; await refreshBalances( @@ -611,30 +773,79 @@ async function backgroundRefresh() { await saveState(); } -setInterval(backgroundRefresh, BACKGROUND_REFRESH_INTERVAL); +// Both recurring jobs run off alarms, not timers. On Chrome MV3 this file is +// a service worker that the browser terminates after about 30 seconds idle, +// so a setInterval would only ever survive until the first idle period and +// module-level state does not outlive it. Alarms are held by the browser and +// wake the worker to deliver them. +registerAlarmHandlers({ + [BALANCE_REFRESH_ALARM]: backgroundRefresh, + // The scheduled refresh, which restores persisted state on a freshly + // revived worker and then fetches unconditionally. The freshness guards + // belong to the startup path; applying them here would make the tick skip + // itself. + [PHISHING_REFRESH_ALARM]: refreshPhishingListOnSchedule, +}); -// Fetch the phishing domain blocklist delta on startup and refresh every 24h. -// The vendored blocklist is bundled at build time; this fetches only new entries. -updatePhishingList(); -startPeriodicRefresh(); +// Everything the background context needs re-established on start. This runs +// on a fresh install, on browser startup, and on every revival of a +// terminated worker, so it must be idempotent: ensureRecurringAlarms() only +// creates alarms that are missing or carrying a stale period, and +// initPhishingList() fetches only when the persisted timestamps say the list +// is stale. +// +// On a fresh install the top-level call and the onInstalled listener both run, +// close enough together that both could see an alarm missing and create it. +// Sharing one in-flight run makes the "create only when missing" check +// race-free; the memo is dropped once it settles so a later onStartup runs +// again. +let backgroundJobsRun = null; -// When approval window is closed without a response, treat as rejection +function startBackgroundJobs() { + if (backgroundJobsRun) return backgroundJobsRun; + backgroundJobsRun = Promise.all([ + ensureRecurringAlarms(), + initPhishingList(), + ]) + .catch((err) => { + // An alarm that failed to schedule means a recurring job silently + // never runs again; it must not be an unhandled rejection. + log.errorf("background job startup failed:", err); + }) + .finally(() => { + backgroundJobsRun = null; + }); + return backgroundJobsRun; +} + +if (runtime.onInstalled) { + runtime.onInstalled.addListener(startBackgroundJobs); +} +if (runtime.onStartup) { + runtime.onStartup.addListener(startBackgroundJobs); +} +startBackgroundJobs(); + +// When approval window is closed without a response, treat as rejection. +// "Without a response" is the operative part: the popup stays open across the +// verify and broadcast it is waiting on, so a user closing an apparently-hung +// window is an ordinary event with an attempt already in flight behind it. +// settleApproval() refuses those, which leaves the attempt to report its real +// outcome to the page. if (windowsApi && windowsApi.onRemoved) { windowsApi.onRemoved.addListener((windowId) => { for (const [id, approval] of Object.entries(pendingApprovals)) { - if (approval.windowId === windowId) { - if (approval.type === "tx" || approval.type === "sign") { - approval.resolve({ - error: { - code: 4001, - message: "User rejected the request.", - }, - }); - } else { - approval.resolve({ approved: false, remember: false }); - } - delete pendingApprovals[id]; - } + if (approval.windowId !== windowId) continue; + const rejection = + approval.type === "tx" || approval.type === "sign" + ? { + error: { + code: 4001, + message: "User rejected the request.", + }, + } + : { approved: false, remember: false }; + settleApproval(id, rejection); } }); } @@ -684,11 +895,16 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => { }; if (approval.type === "tx") { resp.type = "tx"; - resp.txParams = approval.txParams; + // The populated transaction, and the address it was raised + // for. The popup displays and signs exactly this and does not + // populate or re-read anything itself. + resp.approvedTx = approval.approvedTx; + resp.approvedFrom = approval.approvedFrom; } if (approval.type === "sign") { resp.type = "sign"; resp.signParams = approval.signParams; + resp.approvedFrom = approval.approvedFrom; } // Flag if the requesting domain is on the phishing blocklist. resp.isPhishingDomain = isPhishingDomain(approval.hostname); @@ -700,14 +916,10 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => { } if (msg.type === "AUTISTMASK_APPROVAL_RESPONSE") { - const approval = pendingApprovals[msg.id]; - if (approval) { - approval.resolve({ - approved: msg.approved, - remember: msg.remember, - }); - delete pendingApprovals[msg.id]; - } + settleApproval(msg.id, { + approved: msg.approved, + remember: msg.remember, + }); resetPopupUrl(); return false; } @@ -715,57 +927,129 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => { if (msg.type === "AUTISTMASK_TX_RESPONSE") { const approval = pendingApprovals[msg.id]; if (!approval) return false; - delete pendingApprovals[msg.id]; - resetPopupUrl(); + // A reject arriving while an attempt holds the approval is refused, + // not honoured: the attempt is on its way to broadcasting the + // transaction, and resolving 4001 here would tell the page the request + // was rejected while it goes out. if (!msg.approved) { - approval.resolve({ - error: { code: 4001, message: "User rejected the request." }, - }); + if ( + !settleApproval(msg.id, { + error: { + code: 4001, + message: "User rejected the request.", + }, + }) + ) { + sendResponse({ + error: "This transaction is already being sent.", + retryable: false, + stage: TX_STAGE_BROADCAST, + }); + return false; + } return true; } + // The popup signs; it reports back here when it could not. Keep the + // approval so the user can correct the problem and try again with the + // transaction they already saw. + if (msg.error) { + const outcome = describeTxFailure(TX_STAGE_SIGN, msg.error); + sendResponse({ + error: outcome.error, + retryable: outcome.retryable, + stage: TX_STAGE_SIGN, + }); + return false; + } + + // Exactly one broadcast per approval, whatever the popup sends. + if (!claimApproval(approval)) { + sendResponse({ + error: "This transaction is already being sent.", + retryable: false, + stage: TX_STAGE_BROADCAST, + }); + return false; + } + (async () => { try { await loadState(); const activeAddress = await getActiveAddress(); - let wallet, addrIndex; - for (const w of state.wallets) { - for (let i = 0; i < w.addresses.length; i++) { - if (w.addresses[i].address === activeAddress) { - wallet = w; - addrIndex = i; - break; - } - } - if (wallet) break; + // An address switch between approval and signing refuses. The + // approval named one account; signing from whichever account + // is active now would send funds from an account this screen + // never showed. A switch normally rejects every pending + // approval on its way through broadcastAccountsChanged(), so + // this is the case where that did not reach the approval — + // and it is a refusal, not a retry, because the transaction + // the user saw is no longer the transaction that would go out. + if (!sameAddress(activeAddress, approval.approvedFrom)) { + throw new ApprovalMismatchError( + "The active address changed after this transaction was approved, so it was not sent.", + ); } - if (!wallet) throw new Error("Wallet not found"); - // TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage - let decrypted = await decryptWithPassword( - wallet.encryptedSecret, - msg.password, + // The popup holds the secret, but the background stays the + // authority on what is broadcast: the raw transaction must be + // the transaction that was displayed, signed by the address + // the approval named, on the network that is selected. + verifySignedTx( + msg.rawSignedTx, + approval.approvedTx, + approval.approvedFrom, + currentNetwork().chainId, ); - const signer = getSignerForAddress( - wallet, - addrIndex, - decrypted, - ); - // Best-effort: clear decrypted secret after use. - // Note: JS strings are immutable; this nulls the reference but - // the original string may persist in memory until GC. - decrypted = null; + } catch (e) { + // A signed transaction that is not the approved one is not + // retried against that approval; it is refused outright. + // Anything else that failed before the check ran is the + // user's to retry. + const outcome = describeTxFailure(TX_STAGE_VERIFY, e); + if (outcome.spendApproval) { + settleApproval( + msg.id, + { error: { message: outcome.error } }, + { holdsClaim: true }, + ); + } else { + releaseApproval(approval); + } + sendResponse({ + error: outcome.error, + retryable: outcome.retryable, + stage: TX_STAGE_VERIFY, + }); + return; + } + + try { const provider = getProvider(state.rpcUrl); - const connected = signer.connect(provider); - const tx = await connected.sendTransaction(approval.txParams); - approval.resolve({ txHash: tx.hash }); + const tx = await provider.broadcastTransaction(msg.rawSignedTx); + settleApproval( + msg.id, + { txHash: tx.hash }, + { holdsClaim: true }, + ); sendResponse({ txHash: tx.hash }); } catch (e) { - const errMsg = e.shortMessage || e.message; - approval.resolve({ - error: { message: errMsg }, + // Terminal, never retried: the node may have accepted the + // transaction and still failed to answer, so the wallet cannot + // tell a transaction that never left from one already in the + // mempool. The page has been given its outcome for this + // request; a second attempt would report a second one. + const outcome = describeTxFailure(TX_STAGE_BROADCAST, e); + settleApproval( + msg.id, + { error: { message: outcome.error } }, + { holdsClaim: true }, + ); + sendResponse({ + error: outcome.error, + retryable: outcome.retryable, + stage: TX_STAGE_BROADCAST, }); - sendResponse({ error: errMsg }); } })(); return true; @@ -774,73 +1058,82 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => { if (msg.type === "AUTISTMASK_SIGN_RESPONSE") { const approval = pendingApprovals[msg.id]; if (!approval) return false; - delete pendingApprovals[msg.id]; - resetPopupUrl(); + // Same as the transaction path: a reject cannot retire an approval an + // attempt already holds. if (!msg.approved) { - approval.resolve({ - error: { code: 4001, message: "User rejected the request." }, - }); + if ( + !settleApproval(msg.id, { + error: { + code: 4001, + message: "User rejected the request.", + }, + }) + ) { + sendResponse({ + error: "This request is already being signed.", + retryable: false, + stage: TX_STAGE_INFLIGHT, + }); + return false; + } return true; } + // The popup signs; it reports back here when it could not. Keep the + // approval so the user can correct the problem and try again with the + // message they already saw. + if (msg.error) { + sendResponse({ error: msg.error, retryable: true }); + return false; + } + + // Exactly one signature handed back per approval. + if (!claimApproval(approval)) { + sendResponse({ + error: "This request is already being signed.", + retryable: false, + stage: TX_STAGE_INFLIGHT, + }); + return false; + } + (async () => { try { - await loadState(); const activeAddress = await getActiveAddress(); - let wallet, addrIndex; - for (const w of state.wallets) { - for (let i = 0; i < w.addresses.length; i++) { - if (w.addresses[i].address === activeAddress) { - wallet = w; - addrIndex = i; - break; - } - } - if (wallet) break; - } - if (!wallet) throw new Error("Wallet not found"); - // TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage - let decrypted = await decryptWithPassword( - wallet.encryptedSecret, - msg.password, - ); - const signer = getSignerForAddress( - wallet, - addrIndex, - decrypted, - ); - // Best-effort: clear decrypted secret after use. - // Note: JS strings are immutable; this nulls the reference but - // the original string may persist in memory until GC. - decrypted = null; - - const sp = approval.signParams; - let signature; - - if (sp.method === "personal_sign" || sp.method === "eth_sign") { - signature = await signer.signMessage(getBytes(sp.message)); - } else { - // eth_signTypedData_v4 / eth_signTypedData - const typedData = JSON.parse(sp.typedData); - const { domain, types, message } = typedData; - // ethers handles EIP712Domain internally - delete types.EIP712Domain; - signature = await signer.signTypedData( - domain, - types, - message, + // Same as the transaction path: the address the approval named + // is the one that must have signed, and a switch since then is + // a refusal rather than a signature from another account. + if (!sameAddress(activeAddress, approval.approvedFrom)) { + throw new ApprovalMismatchError( + "The active address changed after this request was approved, so it was not signed.", ); } - - approval.resolve({ signature }); + // The popup holds the secret, but the background stays the + // authority on what is handed back to the page: the signature + // must cover the approved payload and recover to the address + // the approval named. + const signature = msg.signature; + verifySignature( + approval.signParams, + signature, + approval.approvedFrom, + ); + settleApproval(msg.id, { signature }, { holdsClaim: true }); sendResponse({ signature }); } catch (e) { const errMsg = e.shortMessage || e.message; - approval.resolve({ - error: { message: errMsg }, - }); - sendResponse({ error: errMsg }); + const retryable = failureIsRetryable(e); + if (!retryable) { + settleApproval( + msg.id, + { error: { message: errMsg } }, + { holdsClaim: true }, + ); + } else { + releaseApproval(approval); + } + sendResponse({ error: errMsg, retryable }); } })(); return true; diff --git a/src/content/inpage.js b/src/content/inpage.js index 8147f49..ee19d0d 100644 --- a/src/content/inpage.js +++ b/src/content/inpage.js @@ -11,6 +11,39 @@ let nextId = 1; const pending = {}; + // EIP-1193 ProviderRpcError: `code`, `message`, optional `data`. A class + // rather than properties bolted onto an Error because this object crosses + // no boundary after construction — it is built in the page's own realm and + // handed straight to the caller's catch — so the prototype survives and + // `error.name` is a stable thing for a dApp to see. + class ProviderRpcError extends Error { + constructor(code, message, data) { + super(message); + this.name = "ProviderRpcError"; + this.code = code; + if (data !== undefined) this.data = data; + } + } + + // Rebuild a boundary error as the error the page catches, carrying the + // code (and data) the extension reported. Without this a dApp cannot tell + // a user's refusal (4001) from a wallet that broke, and retries or shows + // an error instead of accepting the refusal. + // + // Whatever code arrived is passed through verbatim rather than being + // matched against a list: the extension emits 4001, 4100 and 4902 today, + // and a code this file has never heard of is still the truth about what + // happened. An error reported with no code at all stays a plain Error — + // a ProviderRpcError whose `code` is undefined would advertise a + // conformance it does not have. `message` is untouched in every case. + function toPageError(error) { + const message = (error && error.message) || "Request failed"; + if (error && error.code !== undefined && error.code !== null) { + return new ProviderRpcError(error.code, message, error.data); + } + return new Error(message); + } + // Listen for responses from the content script window.addEventListener("message", function onUuid(event) { if (event.source !== window) return; @@ -20,7 +53,7 @@ if (!p) return; delete pending[id]; if (error) { - p.reject(new Error(error.message || "Request failed")); + p.reject(toPageError(error)); } else { p.resolve(result); } diff --git a/src/popup/dustThreshold.js b/src/popup/dustThreshold.js new file mode 100644 index 0000000..7b64c60 --- /dev/null +++ b/src/popup/dustThreshold.js @@ -0,0 +1,42 @@ +// Parsing for the dust threshold field in Settings. +// +// Pure: no DOM, no state, so the accepted set can be unit tested directly +// instead of through the settings view. +// +// Accepted input is plain decimal digits only, meaning a whole number of +// gwei, zero or greater. Zero is a real setting: it hides nothing. +// +// Deliberately rejected, not coerced: +// "" nothing to save +// "-1" a negative threshold has no meaning +// "1.5" fractional gwei is not a threshold the filter can use +// "100 gwei" the unit is already printed beside the field +// "0x10" hex, which Number() would silently read as 16 +// "1e3" exponent notation, which Number() would silently read as 1000 +// +// The last two are the reason this is a digit test and not a Number() test. +// Number() accepts both, and accepting them would put a number in the field +// that the user did not type — the same silent substitution the visible +// rejection message exists to end. + +// Must render on ONE line of #flash-msg, whose reserved height +// (min-h-[1.25rem]) is exactly one line at text-xs. A string long enough to +// wrap to two lines pushes the settings view down, which the No Layout Shift +// policy forbids. Do not lengthen this without re-running the layout test in +// tests/e2e/run.js, which measures the flash line and goes red on a shift. +const DUST_THRESHOLD_MESSAGE = + "Please enter a whole number of gwei, zero or greater."; + +// Returns the threshold in gwei, or null if the input is not one. +function parseDustThresholdGwei(raw) { + if (typeof raw !== "string") return null; + const trimmed = raw.trim(); + if (!/^[0-9]+$/.test(trimmed)) return null; + const val = Number(trimmed); + // A run of digits long enough to exceed Number's exact integer range + // would round on the way in, so it is not a threshold we can store. + if (!Number.isSafeInteger(val)) return null; + return val; +} + +module.exports = { DUST_THRESHOLD_MESSAGE, parseDustThresholdGwei }; diff --git a/src/popup/index.html b/src/popup/index.html index a38e901..54a696a 100644 --- a/src/popup/index.html +++ b/src/popup/index.html @@ -136,7 +136,9 @@ + + +
Show tracked tokens with zero balance +
+ Hide fake tokens impersonating a known symbol +
-
@@ -1098,6 +1142,108 @@
+ + + + + +
+
+
Network fee (max)
+
+
+
+
+
+
Network
+
+
+
+
Nonce
+
+
+