1678 lines
91 KiB
Markdown
1678 lines
91 KiB
Markdown
# AutistMask
|
||
|
||
AutistMask is a GPL-licensed JavaScript browser extension by
|
||
[@sneak](https://sneak.berlin) that provides a minimal Ethereum wallet for
|
||
Chrome and Firefox. It manages HD wallets derived from BIP-39 seed phrases and
|
||
supports sending and receiving ETH and ERC-20 tokens, as well as web3 site
|
||
connection and authentication via the EIP-1193 provider API.
|
||
|
||
The most popular browser-based EVM wallet has a cute mascot, but sucks now. It
|
||
has tracking, ads, preferred placement for swaps, tx broadcast fuckery,
|
||
intercepts tx status links to their own site instead of going to Etherscan, etc.
|
||
None of the common alternatives work on Firefox.
|
||
|
||
Hence, a minimally viable ERC20 browser wallet/signer that works cross-platform.
|
||
Everything you need, nothing you don't. We import as few libraries as possible,
|
||
don't implement any crypto, and don't send user-specific data anywhere but a
|
||
(user-configurable) Ethereum RPC endpoint (which defaults to a public node). The
|
||
extension contacts three user-configurable services: the configured RPC node for
|
||
blockchain interactions, a public CoinDesk API (no API key) for realtime price
|
||
information, and a Blockscout block-explorer API for transaction history and
|
||
token balances. It also fetches a community-maintained phishing domain blocklist
|
||
periodically and performs best-effort Etherscan address label lookups during
|
||
transaction confirmation.
|
||
|
||
In the extension is a hardcoded list of the top ERC20 contract addresses. You
|
||
can add any ERC20 contract by contract address if you wish, but the hardcoded
|
||
list exists to detect symbol spoofing attacks and improve UX.
|
||
|
||
## Getting Started
|
||
|
||
```bash
|
||
git clone https://git.eeqj.de/sneak/autistmask.git
|
||
cd autistmask
|
||
make setup
|
||
make build
|
||
```
|
||
|
||
`make setup` is the entrypoint for a fresh clone: it installs dependencies from
|
||
the lockfile and installs the git pre-commit hook.
|
||
|
||
Load the extension:
|
||
|
||
- **Chrome**: Navigate to `chrome://extensions/`, enable "Developer mode", click
|
||
"Load unpacked", and select the `dist/chrome/` directory.
|
||
- **Firefox**: Navigate to `about:debugging#/runtime/this-firefox`, click "Load
|
||
Temporary Add-on", and select `dist/firefox/manifest.json`.
|
||
|
||
### Debug Builds
|
||
|
||
`make build` always produces a release build: the build-time `DEBUG` constant is
|
||
`false`, so wallet creation uses real entropy and the red banner is off. To
|
||
produce a debug build instead, set `AUTISTMASK_DEBUG=1` in the environment:
|
||
|
||
```bash
|
||
make build-debug # or: AUTISTMASK_DEBUG=1 make build
|
||
```
|
||
|
||
Only the exact value `1` enables it; any other value (including unset, empty, or
|
||
`true`) yields a release build, so a typo cannot accidentally ship the debug
|
||
behavior. The build prints which mode it used. See the
|
||
[DEBUG Mode Policy](#debug-mode-policy) for what the flag changes. **Never
|
||
distribute a debug build** — every wallet it creates gets the same publicly
|
||
known test recovery phrase.
|
||
|
||
Both builds end by running `script/verify-build`, which reads the compiled
|
||
`DEBUG` state back out of the emitted bundles and fails the build if it is not
|
||
the one that was asked for. The test suite cannot check this: it loads
|
||
`src/shared/constants.js` outside a bundle, so it only ever sees the fallback
|
||
value. The assertion is on the artifacts because that is where the property
|
||
lives.
|
||
|
||
## Entrypoints
|
||
|
||
This repository adheres to the
|
||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||
standard: normalized scripts in `script/` are the entrypoints for the
|
||
development workflow, and the Makefile targets are thin shims that call them. We
|
||
provide:
|
||
|
||
- `script/bootstrap` — install all dependencies (pinned node via nvm if needed,
|
||
yarn via corepack, `yarn install --frozen-lockfile`)
|
||
- `script/setup` — make a fresh clone ready for development: bootstrap plus the
|
||
git pre-commit hook
|
||
- `script/projectname` — print the project name (used for the Docker image tag)
|
||
- `script/test` — run the test suite (jest)
|
||
- `script/test-e2e` — run the Chrome browser end-to-end suite (docker required;
|
||
see [End-to-End Tests](#end-to-end-tests))
|
||
- `script/test-e2e-firefox` — run the Firefox browser end-to-end suite (docker
|
||
required; builds its own pinned image, see
|
||
[End-to-End Tests](#end-to-end-tests))
|
||
- `script/lint` — run the linter
|
||
- `script/fmt` — format all files (writes)
|
||
- `script/fmt-check` — check formatting (read-only)
|
||
- `script/check` — run test, test-verify-build, lint, and fmt-check
|
||
- `script/verify-build` — assert the compiled `DEBUG` state of the bundles in
|
||
`dist/`: every bundle containing `src/shared/constants.js` must have `DEBUG`
|
||
off, or on when `AUTISTMASK_DEBUG=1`. Run automatically at the end of
|
||
`make build` and `make build-debug`; fails loudly rather than passing if it
|
||
cannot determine a bundle's state. Not part of `make check`, which does not
|
||
depend on build artifacts existing.
|
||
- `script/test-verify-build` — exercise every failure mode of
|
||
`script/verify-build` against a fixture tree in a temp dir, asserting the exit
|
||
status and the message of each. Part of `make check`; it reads no build
|
||
artifacts and writes nothing under `dist/`. The cases that depend on file
|
||
permissions cannot mean anything for a process that is not subject to them, so
|
||
the harness proves its runner against a mode-000 file before counting them,
|
||
dropping to an unprivileged user when run as root; if it cannot, it skips
|
||
those cases and says so in a banner rather than passing them.
|
||
- `script/docker` — build the Docker image tagged via `script/projectname`
|
||
- `script/cibuild` — CI entrypoint: plain `docker build .`
|
||
- `script/precommit` — run by the git pre-commit hook; runs `script/check`
|
||
- `script/install-precommit` — install the git pre-commit hook
|
||
|
||
The Makefile shims to those. It also carries a few targets that have no
|
||
`script/` counterpart and are Makefile-only conveniences:
|
||
|
||
- `make install` — `yarn install --frozen-lockfile` on its own, without the rest
|
||
of `script/bootstrap`. Frozen so a stale `yarn.lock` fails instead of being
|
||
silently rewritten. Use `make setup` for a fresh clone.
|
||
- `make hooks` — shims to `script/install-precommit`
|
||
- `make build` — build the extension into `dist/chrome/` and `dist/firefox/`
|
||
- `make build-debug` — the same build with `AUTISTMASK_DEBUG=1` (see
|
||
[Debug Builds](#debug-builds))
|
||
- `make clean` — remove `dist/`
|
||
- `make dev` — build in watch mode
|
||
|
||
## End-to-End Tests
|
||
|
||
There are two suites, one per browser, and they share no code. Chrome runs on
|
||
Playwright; Firefox has its own WebDriver client, because Playwright cannot
|
||
observe errors on a Firefox extension page at all — see
|
||
[Firefox](#firefox-make-test-e2e-firefox) below. Both require docker, and both
|
||
are outside `make check`.
|
||
|
||
### Chrome (`make test-e2e`)
|
||
|
||
`make test-e2e` builds `dist/chrome/` and drives the **real popup in a real
|
||
Chrome**, loaded as an unpacked MV3 extension inside a pinned
|
||
`mcr.microsoft.com/playwright` container (pinned by digest in `script/test-e2e`;
|
||
docker is required and the suite fails loudly rather than skipping if it is
|
||
unavailable). The suite lives in `tests/e2e/` and is driven by
|
||
`playwright-core`, whose version must stay matched to the container's Playwright
|
||
version — the browsers ship inside the image.
|
||
|
||
It covers popup load, WebAssembly compilation under the shipped CSP (see
|
||
[Content Security Policy](#content-security-policy)), wallet creation through
|
||
the UI, the Add Token screen, the transaction detail screen for an ERC-20
|
||
transfer, and the recovery phrase screen — which wallet types are offered it,
|
||
that it holds nothing before the password is accepted, that a wrong password
|
||
reveals nothing, that leaving it by either route wipes it — including a leave
|
||
taken while the decrypt is still running — and that reopening the popup does not
|
||
land on it. It also covers address removal: which wallets offer the control at
|
||
all, that the confirmation states the route back rather than showing an empty
|
||
paragraph, that leaving the confirmation removes nothing, and that confirming it
|
||
does. All outbound network is intercepted at the browser level and served from
|
||
fixtures in `tests/e2e/network.js`, so the run is deterministic and fully
|
||
offline; unrecognised outbound requests are reported as failures rather than
|
||
silently allowed.
|
||
|
||
It also covers the confirmation screen, for both a native ETH send and an ERC-20
|
||
send: Send disabled while the fee estimate is in flight, enabled once it lands,
|
||
the fee block quoting the expected cost and the reserve separately, the distinct
|
||
message for an estimate that failed, and the view height staying constant across
|
||
every one of those transitions. The load-bearing one is that the spend gate uses
|
||
the **reserve** and not the displayed **estimate** — the two are stubbed far
|
||
apart on purpose, and the funded and refused sends sit on opposite sides of the
|
||
reserve while sitting on the same side of the estimate, so swapping the two in
|
||
`src/popup/views/confirmTx.js` fails the suite instead of passing it. That is
|
||
what [#154](https://git.eeqj.de/sneak/AutistMask/issues/154) was, and it was
|
||
previously correct by reading only.
|
||
|
||
It also covers the **dApp approval round trips** — the one place where the
|
||
content script, the inpage provider, the background worker and the approval
|
||
popup all have to work together. A local test page is served by the route
|
||
handler on a reserved-TLD origin, gets `window.ethereum` from the shipped
|
||
`MAIN`-world content script like any other page, and drives
|
||
`eth_requestAccounts`, `personal_sign`, `eth_signTypedData_v4` and
|
||
`eth_sendTransaction` through the real prompts. Every signature is recovered in
|
||
the runner and compared against the active address, the transaction assertions
|
||
run against the raw signed transaction captured at `eth_sendRawTransaction`
|
||
rather than against anything the extension reported, rejecting each prompt is
|
||
required to return a rejection to the page rather than hang or resolve, and the
|
||
password is required to be absent from every message the approval window sends
|
||
to the background — with the message that would carry it required to be present,
|
||
so that check cannot pass by observing nothing. That last one is the standing
|
||
floor under [#157](https://git.eeqj.de/sneak/AutistMask/issues/157).
|
||
|
||
Three limits of that coverage, none of them papered over. The RPC is stubbed
|
||
throughout, so this is **not** a real dApp against a real network with real
|
||
funds; that remains a human pass before 1.0.0. The site-connection prompt is
|
||
raised through `chrome.action.openPopup()`, and headless Chromium's
|
||
browser-action popup is not a page Playwright can see or click, so that one
|
||
prompt is driven at the URL the extension itself puts on the action — the same
|
||
page and the same approval id, but whether a real toolbar click shows it is not
|
||
observable here. And the EIP-1193 error code does not survive the last hop: the
|
||
rejection that crosses the boundary carries code 4001 and is asserted to, but
|
||
`src/content/inpage.js` rebuilds it as `new Error(message)`, so the calling page
|
||
catches an error with no `code` property.
|
||
|
||
Any test that drives a failure path on purpose declares the `console.error` it
|
||
is about to provoke, via `errors.expect()`. That is not a mute: the declaration
|
||
consumes exactly one matching record, and a declaration nothing matched fails
|
||
its test just as an undeclared error does.
|
||
|
||
That reporting has one bound worth knowing. Observation ends when the browser
|
||
context is torn down, and nothing can watch traffic after that, so the run keeps
|
||
collecting for a fixed grace period after the last test returns
|
||
(`TRAILING_WATCH_MS` in `tests/e2e/run.js`, currently 1500ms) and then closes
|
||
the context. A request whose _first_ dispatch falls after that window is never
|
||
seen at all and cannot fail the run. In practice a request a test fires without
|
||
awaiting reaches the route handler about 10ms later, and anything on a repeating
|
||
timer gets observed on an earlier tick during the ~20s suite — but a one-shot
|
||
call deliberately deferred past the window will escape.
|
||
|
||
That interception covers the MV3 background service worker as well as the popup
|
||
page, which it does not by default — `script/test-e2e` sets
|
||
`PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1` for it. Because that flag is
|
||
experimental, the harness does not take it on trust. At launch it waits for the
|
||
background worker's **own** startup request — the phishing blocklist fetch that
|
||
`src/background/index.js` issues on startup, which on the suite's throwaway
|
||
profile always happens because no previous fetch timestamp is persisted — to
|
||
arrive in the route handler, and aborts the entire suite if none does within 30
|
||
seconds (`tests/e2e/harness.js`). The check is passive on purpose: a synthetic
|
||
probe fetched from inside the worker via `worker.evaluate()` was tried first and
|
||
rejected, because evaluating in an extension service worker that early kills the
|
||
worker outright, destroying the thing being measured. Observing traffic the
|
||
extension already generates perturbs nothing. Losing the race fails closed — the
|
||
suite refuses to run rather than passing quietly.
|
||
|
||
As defence in depth, Chrome is also started with
|
||
`--host-resolver-rules=MAP * ~NOTFOUND`, so a request that ever did slip past
|
||
the route handler could not resolve a host at all. That only bounds the damage;
|
||
detecting escaping traffic remains the canary's job. To see what is actually
|
||
being intercepted, run with `E2E_TRACE_NETWORK=1` and every routed request is
|
||
printed, tagged `[sw]` or `[page]`.
|
||
|
||
**Any uncaught page error or `console.error` fails the run.** That is the point:
|
||
a `ReferenceError` from a used-but-not-imported identifier is invisible to
|
||
`make check` (`script/lint` is only `prettier --check`) but fatal in a browser,
|
||
and this suite exists because exactly that class of bug shipped twice.
|
||
|
||
### Firefox (`make test-e2e-firefox`)
|
||
|
||
`make test-e2e-firefox` builds `dist/firefox/` and drives the **real popup in a
|
||
real Firefox**, installed as an unpacked MV2 temporary add-on via geckodriver.
|
||
It covers popup load, wallet creation through the UI, and the Add Token screen.
|
||
The suite lives in `tests/e2e/firefox/` and has **no npm dependencies at all**:
|
||
it is a small WebDriver client built on global `fetch` and `child_process`
|
||
against geckodriver's HTTP API.
|
||
|
||
Unlike the Chrome suite it builds its own container image rather than pulling a
|
||
published one, because no published image carries both a pinned Firefox and a
|
||
matching geckodriver. `tests/e2e/firefox/Dockerfile` pins all three external
|
||
artifacts by digest — the `node` base image, the Firefox 153.0.3 tarball, and
|
||
geckodriver 0.36.0 — and the Firefox version in particular must not float:
|
||
`-remote-allow-system-access` is **mandatory** on 153 and was not on 142.
|
||
Without that flag, both navigating to `moz-extension://` and running
|
||
chrome-context script fail with `unsupported operation`. The flag grants the
|
||
driver full chrome privileges over that browser, which is acceptable only
|
||
because it is a throwaway container.
|
||
|
||
The popup's `moz-extension://` uuid is **pinned, not discovered**: the profile
|
||
pref `extensions.webextensions.uuids` maps the extension id that
|
||
`manifest/firefox.json` already declares to a fixed uuid, so the popup URL is
|
||
deterministic. Navigation uses **classic** WebDriver `POST /session/{id}/url`,
|
||
because BiDi's `browsingContext.navigate` refuses `moz-extension://` outright.
|
||
|
||
**Any uncaught error from a `moz-extension://` source fails the run**, including
|
||
errors from the background page, which the suite never navigates to: a `throw`
|
||
at the top of `src/background/index.js` kills the background page and fails
|
||
step 1. Content-script errors should arrive by the same route, but this suite
|
||
does not exercise it and does not claim it — with `--network none` there is no
|
||
`http://` page for a content script to be injected into. Errors from add-on
|
||
install and background startup are folded into step 1 rather than discarded.
|
||
Errors are read from the privileged `nsIConsoleService` in Marionette's chrome
|
||
context and filtered to non-warning entries whose `sourceName` is the extension
|
||
origin. That mechanism is not a stylistic choice. WebDriver BiDi's
|
||
`log.entryAdded` delivers **nothing** for extension pages: on a plain `http://`
|
||
page it reports uncaught errors with stack traces, and on the `moz-extension://`
|
||
popup it reports zero events, because Firefox's remote agent excludes extension
|
||
browsing contexts from BiDi observation. Any harness built on Playwright-BiDi or
|
||
Puppeteer-BiDi would therefore see nothing and report success, which is exactly
|
||
the vacuous check this repo has already shipped twice. Do not migrate this suite
|
||
to BiDi.
|
||
|
||
Two limits are worth knowing, both real differences from the Chrome suite:
|
||
|
||
- **Error capture is poll-based, not event-streamed.** The console is drained at
|
||
each step boundary, so an error is attributed to the step it was drained
|
||
after, not to a moment within it. The window that is drained runs from add-on
|
||
install to **≈1.5s** after the last step returns — a 500ms settle, a 1000ms
|
||
tail sleep and two drain round trips — and then the browser is torn down. That
|
||
cut-off is not a hard boundary: with throws scheduled at fixed offsets, three
|
||
runs reported everything up to +1.5s and one of the three also reported +1.6s,
|
||
so an error landing near it may or may not be seen, and anything well past it
|
||
is not. Inside the window there is no race — each drain reads and clears the
|
||
console in a single chrome round trip, so an error logged mid-drain lands in
|
||
that batch or the next rather than being destroyed unread — but there is a
|
||
**capacity limit**: `nsIConsoleService` keeps a ring buffer of 250 messages
|
||
and silently evicts the oldest, so more than 250 console messages between two
|
||
drains destroys the excess unread. 400 throws inside one step are reported as
|
||
exactly the newest 250, three runs running. That buffer is shared with
|
||
Firefox's own console noise; a clean run peaks at 4 of 250 at the install
|
||
drain and 0 at every later drain, so the three steps here have wide headroom,
|
||
but a step that logs heavily could evict unread errors. What poll-based costs
|
||
is location, not coverage: an error cannot be placed within a step the way the
|
||
Chrome suite's `pageerror` events place it.
|
||
- **Nothing is stubbed, which inverts the coverage of network-dependent code.**
|
||
There is no fixture layer; the container runs with `--network none` instead,
|
||
so the run is offline and deterministic and no request can escape. The
|
||
extension swallows its own fetch failures, so the flows are unaffected — but
|
||
every network call fails, so only the _failure_ branches of code that depends
|
||
on one are ever executed. A `ReferenceError` in the success path of
|
||
`renderTransactions`, or of price or balance rendering, passes this suite
|
||
green. The offline run is also weaker than the Chrome suite's interception: it
|
||
proves nothing got out, but it cannot report which requests were attempted.
|
||
Closing that gap needs a fixture layer, deliberately out of scope for this
|
||
harness.
|
||
|
||
Neither `make test-e2e` nor `make test-e2e-firefox` is part of `make check` or
|
||
`make test`. `REPO_POLICIES.md` caps `make test` at 20 seconds and a browser
|
||
suite does not fit; nothing in `tests/e2e/` is named `*.test.js`, so jest cannot
|
||
pick it up either. Neither is wired into the Gitea workflow yet —
|
||
docker-in-docker in CI is a separate question. Run them locally before changing
|
||
anything under `src/popup/views/`.
|
||
|
||
## Rationale
|
||
|
||
Common popular EVM wallets have become bloated with swap UIs, portfolio
|
||
dashboards, analytics, tracking, and advertisements. It is no longer a simple
|
||
wallet. Most alternatives only support Chromium browsers, leaving Firefox users
|
||
without a usable option.
|
||
|
||
AutistMask exists to provide the absolute minimum viable Ethereum wallet
|
||
experience: manage seed phrases, derive HD addresses, send and receive ETH and
|
||
ERC-20 tokens, and connect to web3 sites. Nothing else. No swaps (that's what
|
||
the web is for), no analytics, no tracking, no ads, no portfolio views, no NFT
|
||
galleries. Just a wallet.
|
||
|
||
## Design
|
||
|
||
AutistMask is a browser extension targeting both Chrome (Manifest V3) and
|
||
Firefox (Manifest V2/V3 as supported). The codebase is shared between both
|
||
targets with platform-specific manifest files and a build step that produces
|
||
separate output directories.
|
||
|
||
### Architecture
|
||
|
||
```
|
||
src/
|
||
background/ — service worker / background script
|
||
index.js — RPC routing, approval flows, message signing
|
||
content/ — content script injected into web pages
|
||
index.js — relay between inpage provider and background
|
||
inpage.js — the window.ethereum provider object (EIP-1193)
|
||
popup/ — popup UI (the main wallet interface)
|
||
index.html
|
||
index.js — entry point, view routing, state restore
|
||
styles/main.css — Tailwind source
|
||
views/ — one JS module per screen (home, send, approval, etc.)
|
||
shared/ — modules used by both popup and background
|
||
alarms.js — recurring background jobs (extension alarms API)
|
||
balances.js — ETH + ERC-20 balance fetching via RPC + Blockscout
|
||
constants.js — chain IDs, default RPC endpoint, ERC-20 ABI
|
||
ens.js — ENS forward/reverse resolution (popup only)
|
||
prices.js — ETH/USD and token/USD via CoinDesk API
|
||
scamlist.js — known fraud contract addresses
|
||
state.js — persisted state (extension storage)
|
||
symbolSpoof.js — the known-symbol spoof rule, shared by all surfaces
|
||
tokenList.js — top ERC-20 tokens by market cap (hardcoded)
|
||
transactions.js — tx history fetching + anti-poisoning filters
|
||
uniswap.js — Uniswap Universal Router calldata decoder
|
||
vault.js — password-based encryption via libsodium
|
||
wallet.js — mnemonic generation, HD derivation, signing
|
||
manifest/
|
||
chrome.json — Manifest V3 for Chrome
|
||
firefox.json — Manifest V2 for Firefox
|
||
```
|
||
|
||
### Background scheduling
|
||
|
||
Chrome runs `src/background/index.js` as a Manifest V3 service worker, which the
|
||
browser terminates after roughly 30 seconds idle and re-evaluates from scratch
|
||
on the next event. Two consequences shape every recurring job in the background:
|
||
|
||
- `setInterval` and `setTimeout` are useless. They are destroyed with the
|
||
worker, so a job scheduled that way runs until the first idle period and never
|
||
again. Both recurring jobs — the 60-second balance refresh and the 24-hour
|
||
phishing blocklist refresh — are scheduled through the extension alarms API
|
||
(`src/shared/alarms.js`) instead. The browser holds the schedule and wakes the
|
||
worker to deliver it. Alarm periods are clamped to a one-minute minimum, so
|
||
the balance refresh is expressed as exactly one minute and nothing is silently
|
||
slowed down.
|
||
- Module-level variables do not survive either. Anything that must be remembered
|
||
across a restart goes in extension storage, including the timestamp of the
|
||
last phishing list fetch: without it a revived worker would either re-fetch on
|
||
every wake or, with a naive in-memory guard, never notice that an update is
|
||
due. `localStorage` does not exist in a service worker at all — the one
|
||
remaining user of it, `src/shared/ens.js`, runs only in the popup and is
|
||
marked as such.
|
||
|
||
Both jobs also carry a freshness guard, and a guard must never be timed to the
|
||
alarm period it gates. Each guard is measured from the moment the last run
|
||
finished, which is one run-duration after the alarm that started it, so a guard
|
||
of exactly one period vetoes the very next tick and the real cadence becomes two
|
||
periods. The two jobs solve this differently, because their guards exist for
|
||
different reasons:
|
||
|
||
- The phishing refresh has a 24-hour cache TTL whose job is to keep the worker
|
||
off the network on the wakes between scheduled refreshes — Chrome revives the
|
||
worker every ~30 seconds while the browser is busy, and every revival runs the
|
||
startup path. The scheduled alarm tick is not one of those wakes, so it
|
||
bypasses the TTL and fetches unconditionally. Shortening the TTL instead would
|
||
not work: the startup path re-checks it on every wake, so a shorter TTL simply
|
||
becomes the real refresh rate.
|
||
- The balance refresh guard exists to skip work an open popup has already done —
|
||
the popup refreshes every 10 seconds and stamps the same field. That has to
|
||
keep applying on the scheduled tick, so the guard is shortened to half the
|
||
alarm period instead of bypassed: comfortably above the popup's 10 seconds, so
|
||
an open popup still suppresses the background job, and comfortably below the
|
||
60-second period, so the schedule always wins.
|
||
|
||
Two timestamps are persisted for the phishing list, not one. `lastFetchTime`
|
||
records a fetch that produced a usable delta and drives the TTL.
|
||
`lastAttemptTime` records that the network was contacted at all, and is written
|
||
even when the result is unusable — a failed request, or a delta over the 256 KiB
|
||
cap. Without it those cases leave no freshness mark and the worker re-downloads
|
||
the full blocklist on every wake, indefinitely; with it, unscheduled retries are
|
||
floored at one hour. Both are discarded on load if they are in the future, since
|
||
a stamp from a skewed clock or a restored backup would otherwise suppress
|
||
updates until that time arrives, permanently and with no way out.
|
||
|
||
The startup path (`ensureRecurringAlarms()` plus the phishing list init) runs on
|
||
`onInstalled`, on `onStartup`, and at the top level of the worker, so every way
|
||
the background context can start re-establishes the schedule. On a fresh install
|
||
more than one of those fires, so they share a single in-flight run rather than
|
||
racing. It is idempotent: an alarm that already exists with the period the code
|
||
asks for is left alone, because re-creating one restarts its schedule and a busy
|
||
extension would push the next fire out indefinitely. An alarm carrying a
|
||
different period — one created by an earlier version — is re-created once, or a
|
||
period changed in a new release would never reach an existing install.
|
||
|
||
Firefox uses Manifest V2 with a persistent background page, where timers would
|
||
survive. Both browsers are built from one bundle and both take the alarm path,
|
||
so there is a single code path to reason about; `"alarms"` is declared in both
|
||
`manifest/chrome.json` and `manifest/firefox.json`.
|
||
|
||
### UI Design Philosophy
|
||
|
||
The UI is inspired by _Universal Paperclips_. It's deliberately minimal,
|
||
monochrome, fast, and includes once-popular usability affordances that seem to
|
||
have fallen out of fashion in modern UI design. Clickable things look clickable.
|
||
Things don't flash or spin or move around unnecessarily. This is a tool for
|
||
getting work done, not a toy.
|
||
|
||
This is designed for a normal audience. Basic familiarity with cryptocurrency
|
||
terms is required, but you need not be a programmer or software engineer to use
|
||
this wallet.
|
||
|
||
If you _are_ basically familiar with cryptocurrency terms, you should be able to
|
||
use all of the main features of this wallet without having to read the
|
||
documentation; i.e. we wish for the primary functionality to remain easily
|
||
discoverable.
|
||
|
||
#### Visual Style
|
||
|
||
- **Monochrome**: Black text on white background. Color is only used when and
|
||
where it is semantically meaningful and explicitly useful, such as error
|
||
messages, critical warnings, or address disambiguation. (Notable exception: we
|
||
use color dots, and identicons, to help a user easily distinguish addresses.)
|
||
- **Text-first**: Every piece of information is presented as text. Balances are
|
||
numbers. Addresses are hex strings. Flash messages are sentences. All fiddly
|
||
bits can be clicked to copy to the clipboard, and external links to Etherscan
|
||
are provided everywhere they might be useful.
|
||
- **Monospace font**: All text is rendered in the system monospace font.
|
||
Ethereum addresses, transaction hashes, and balances are inherently
|
||
fixed-width data. Rather than mixing proportional and monospace fonts, we use
|
||
monospace everywhere for visual consistency and alignment.
|
||
- **No images**: Zero image assets in the entire extension. No logos, no
|
||
illustrations, no token icons. Token identity is conveyed by symbol text (ETH,
|
||
USDC, etc.). We display
|
||
[Blockie identicons](https://github.com/MyCryptoHQ/ethereum-blockies-base64)
|
||
on critical screens and when space is available to allow users to disambiguate
|
||
addresses visually, as a security feature.
|
||
- **Tailwind CSS**: Utility-first CSS via Tailwind. No custom CSS classes for
|
||
styling. Tailwind is configured with a minimal monochrome palette. This keeps
|
||
the styling co-located with the markup and eliminates CSS file management.
|
||
- **Vanilla JS**: No framework (React, Vue, Svelte, etc.). The popup UI is small
|
||
enough that vanilla JS with simple view switching is sufficient. A framework
|
||
would add bundle size, build complexity, and attack surface for no benefit at
|
||
this scale.
|
||
- **360x600 popup**: Standard browser extension popup dimensions. The UI is
|
||
designed for this fixed viewport.
|
||
|
||
#### No Layout Shift
|
||
|
||
Asynchronous state changes (clipboard confirmation, transaction status, error
|
||
messages, flash notifications, API results returning) must _never_ move around
|
||
the existing UI elements. All dynamic content areas must reserve their space up
|
||
front using `min-height` or always-present wrapper elements.
|
||
`visibility: hidden` is preferred over `display: none` when the element's space
|
||
must be preserved. This prevents jarring content jumps that disorient users and
|
||
can cause dangerous mis-clicks. Anyone who has multi-tabled on ClubGG and
|
||
smashed the big red "all-in blind preflop" button when trying to simply "call"
|
||
on a different table knows exactly tf I am talking about.
|
||
|
||
#### Clickable Affordance
|
||
|
||
Every interactive element must visually indicate that it is clickable. Buttons
|
||
use a visible border, padding, and a hover state (invert to white-on-black).
|
||
Text that triggers an action (e.g. "Add additional wallet...") uses an
|
||
underline. No invisible hit targets, no bare text that happens to have a click
|
||
handler. If it does something when you click it, it must look like it does
|
||
something when you click it.
|
||
|
||
#### Display Consistency
|
||
|
||
The same data must be formatted identically everywhere it appears. Token and ETH
|
||
amounts are displayed with exactly 4 decimal places (e.g. "1.0500 ETH", "17.1900
|
||
USDT") in balance lists, transaction lists, send confirmations, and approval
|
||
screens. Timestamps include both an ISO datetime and a humanized relative age
|
||
wherever shown. If a formatting rule applies in one place, it applies in every
|
||
place. Users should never see the same value rendered differently on two
|
||
screens.
|
||
|
||
**Specific Exception — Truncation:** On some non-critical display locations, we
|
||
may truncate _a small number_ of characters from the middle of an address solely
|
||
due to display size constraints. Wherever possible, and, notably, **in all
|
||
critical contexts (transaction confirmation view before signing, transaction
|
||
history detail view) addresses will _NEVER_ be truncated**. Even in places we
|
||
truncate addresses, we truncate only a maximum of 10 characters, which means
|
||
that the portions still displayed will be more than adequate for the user to
|
||
verify addresses even in the case of address spoofing attacks. Clicking an
|
||
address will always copy the full, untruncated value.
|
||
|
||
**Specific Exception — Transaction Detail view:** The transaction detail screen
|
||
is the authoritative record of a specific transaction and shows the exact,
|
||
untruncated amount with all meaningful decimal places (e.g. "0.00498824598498216
|
||
ETH"). It also shows the native quantity (e.g. "4988245984982160 wei") below it.
|
||
Both are click-copyable. Truncating to 4 decimals in summary views is acceptable
|
||
for scannability, but the detail view must never discard precision — it is the
|
||
one place the user can always use to verify exact details.
|
||
|
||
#### Language & Labeling
|
||
|
||
All user-facing text avoids unnecessary jargon wherever possible:
|
||
|
||
- "Recovery phrase" instead of "seed phrase", "mnemonic", or "BIP-39 mnemonic"
|
||
- "Address" instead of "account", "derived key", or "HD child"
|
||
- "Password" instead of "encryption key" or "vault passphrase"
|
||
- Buttons use plain verbs: "Send", "Receive", "Copy address", "Add", "Back",
|
||
"Cancel", "Lock", "Unlock", "Allow", "Deny"
|
||
- Helpful inline descriptions where needed (e.g. "This password locks the wallet
|
||
on this device. It is not the same as your recovery phrase.")
|
||
- Error messages are full sentences ("Please enter your password." not "password
|
||
required")
|
||
|
||
#### Full Identifiers Policy
|
||
|
||
Addresses, transaction hashes, contract addresses, and all other cryptographic
|
||
identifiers are displayed in full whenever possible. We truncate only in
|
||
specific, limited, non-critical places and even then only a small amount that
|
||
still prevents spoofing attacks. Address poisoning attacks exploit truncated
|
||
displays by generating fraud addresses that share the same prefix and suffix as
|
||
a legitimate address. If a user only sees `0xAbCd...1234`, an attacker can
|
||
create an address with the same visible characters and trick the user into
|
||
sending funds to it. Showing the complete identifier defeats this class of
|
||
attack.
|
||
|
||
#### Data Model
|
||
|
||
The core hierarchy is **Wallets → Addresses**:
|
||
|
||
- A **wallet** is one of three types:
|
||
- An **HD wallet** (`type: "hd"`, recovery phrase): generates multiple
|
||
addresses from a single 12/24 word recovery phrase using BIP-39/BIP-44
|
||
derivation. The user can add more addresses with a "+" button.
|
||
- A **key wallet** (`type: "key"`, private key): a single address imported
|
||
directly from a private key. No "+" button since there is only one
|
||
address.
|
||
- An **xprv wallet** (`type: "xprv"`, extended private key): the same
|
||
multi-address behavior as an HD wallet, including the "+" button and the
|
||
address scan on import, but imported from an extended private key rather
|
||
than a recovery phrase. It therefore has no recovery phrase to display or
|
||
back up. Only a master key may be imported; an xprv wallet already in
|
||
storage that was imported from a non-master key is detected from the depth
|
||
of its stored `xpub` by `src/shared/walletDefects.js`, explained in the
|
||
wallet list, and blocked from signing, sending and private-key export. It
|
||
is never deleted or rewritten.
|
||
- An **address** holds ETH and ERC-20 tokens.
|
||
- The user can have multiple wallets, each with multiple addresses (HD) or a
|
||
single address (key).
|
||
|
||
Which tokens an address shows is decided by `fetchTokenBalances()` in
|
||
`src/shared/balances.js`, from the Blockscout `token-balances` response, so
|
||
tokens do appear without the user adding them. An ERC-20 is shown when its
|
||
balance is nonzero and it is in the bundled known-token list, is tracked by the
|
||
user, or has 1,000 or more holders; a token claiming a symbol from the bundled
|
||
list from any other contract address is always dropped, and so is any token
|
||
claiming a symbol that belongs to the native asset and therefore has no
|
||
legitimate contract at all (`"ETH"`). That filter is unconditional — the "Hide
|
||
tokens with fewer than 1,000 holders" setting governs the transaction history
|
||
and the send-screen token selector, not this list. Tracked tokens with a zero
|
||
balance are listed as well while "Show tracked tokens with zero balance" is on.
|
||
|
||
#### Navigation
|
||
|
||
The main view shows all addresses grouped by wallet, with ETH balances inline.
|
||
The user taps an address to see its detail view (full address, balance, tokens,
|
||
send/receive). Navigation is a stack: each forward action pushes the current
|
||
screen, and every view has a "Back" or "Cancel" button that pops back to it (see
|
||
the Screen Map below). There is no hamburger menu and no persistent tab bar; the
|
||
Settings gear in the title bar is the only global control. Two screens carry an
|
||
in-screen control beyond that: AddWallet uses three tabs to select the import
|
||
mode, and AddressDetail keeps its one rarely-used action ("Export Private Key")
|
||
behind a "···" menu.
|
||
|
||
### Screen Map
|
||
|
||
Navigation uses a stack model (like iOS): each forward action pushes the current
|
||
screen onto `state.viewStack`, and "Back" pops it (`pushCurrentView()` and
|
||
`goBack()` in `src/popup/views/helpers.js`). The root screen is either Welcome
|
||
(no wallets) or Home (has wallets). Each screen below gives its view id in
|
||
parentheses; the registry of view ids is the `VIEWS` array in
|
||
`src/popup/views/helpers.js`, and the markup for a screen is the element with id
|
||
`view-` plus that view id in `src/popup/index.html`.
|
||
|
||
Three elements sit outside the screens and are present on all of them: the title
|
||
bar ("AutistMask by @sneak" plus the Settings gear), the flash message line
|
||
under it, and the red banner at the very top that appears on a debug build, when
|
||
runtime debug mode is on, or when the active network is a testnet. They are not
|
||
repeated in the element lists below.
|
||
|
||
Closing and reopening the popup returns to the screen the user was last on only
|
||
for the views listed in `RESTORABLE_VIEWS` (`src/popup/restorableViews.js`).
|
||
Every other screen falls back to Home. The screens that display a secret —
|
||
ExportPrivKey and ShowRecoveryPhrase — are deliberately absent from that list,
|
||
so the popup can never reopen onto one of them with no password prompt in front
|
||
of it.
|
||
|
||
A reopened popup renders the wallet list and the one screen it restores onto,
|
||
and nothing else, so every screen on the stack behind that one is still the
|
||
blank template from `index.html`. "Back" therefore renders its target rather
|
||
than only unhiding it, through the same dispatch and data guards as the restore
|
||
(`src/popup/viewRouter.js`), and falls back to Home when the state the target
|
||
would render is gone.
|
||
|
||
It renders only a screen this page load has not rendered yet. Forward navigation
|
||
renders as it goes, and `viewRouter.js` records every screen that reaches
|
||
`showView()`, so "Back" onto a screen already on the page unhides it and nothing
|
||
more — rendering it a second time would re-fetch and overwrite what it holds,
|
||
such as an edit typed into Settings and not yet saved. Home is the one screen
|
||
"Back" always re-renders, so the wallet list reflects anything that changed
|
||
while the user was away from it.
|
||
|
||
Every screen that holds secret material in the page registers a cleanup with
|
||
`onViewLeave()` (`src/popup/views/helpers.js`), which `showView()` runs on every
|
||
exit from that screen rather than only on its "Back" button, so nothing secret
|
||
survives in a hidden view once the user has navigated away by any route. That
|
||
covers the revealed private key and recovery phrase, the recovery phrase,
|
||
private key or extended private key entered on AddWallet, and the password typed
|
||
on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
|
||
|
||
#### Welcome (`welcome`)
|
||
|
||
- **When**: No wallets exist yet (`state.hasWallet` is false). This is the root
|
||
screen in that case.
|
||
- **Elements**:
|
||
- "Welcome! To get started, add a wallet." text
|
||
- "Add wallet" button
|
||
- **Transitions**:
|
||
- "Add wallet" → **AddWallet**
|
||
|
||
#### Home (`main`)
|
||
|
||
- **When**: At least one wallet exists. This is the root screen.
|
||
- **Elements**:
|
||
- Active address ETH balance (large) + USD value in parentheses
|
||
- "Total:" USD value across ETH and every token shown for the active address
|
||
- Active address (color dot, full address, etherscan link, tap to copy)
|
||
- Send / Receive quick-action buttons, both acting on the active address
|
||
- ETH/USD price display
|
||
- Wallet list: each wallet shows its name (tap to rename inline) and a "+"
|
||
button for HD and xprv wallets, then one block per address with "Address
|
||
N" (bold when active), the ENS name if resolved, the full address, an
|
||
`[info]` button, an `[x]` button (only on HD and xprv wallets holding more
|
||
than one address), the address USD total, and a balance line for ETH and
|
||
for each token shown for that address
|
||
- "Recent Transactions": up to 25 transactions merged across every address
|
||
of every wallet, deduplicated by hash and filtered
|
||
- "Add additional wallet..." link at bottom
|
||
- **Transitions**:
|
||
- Tap address row → sets the active address and broadcasts
|
||
`AUTISTMASK_ACTIVE_CHANGED` (no screen change)
|
||
- Tap wallet name → inline rename field (no screen change)
|
||
- "+" on wallet → derives the next address inline (no screen change)
|
||
- `[info]` on address → **AddressDetail**
|
||
- `[x]` on address → **DeleteAddress**
|
||
- "Send" → **Send** (refuses with a flash message on a zero balance)
|
||
- "Receive" → **Receive** (shows active address QR)
|
||
- Tap home tx row → **TransactionDetail**
|
||
- "Add additional wallet..." → **AddWallet**
|
||
- Settings gear → **Settings** (toggles; tap again to return)
|
||
|
||
#### AddWallet (`add-wallet`)
|
||
|
||
- **When**: User wants to add a new wallet (from Welcome, Home, or Settings).
|
||
This one screen covers all three import modes; there is no separate import
|
||
screen.
|
||
- **Elements**:
|
||
- "Back" button, "Add Wallet" heading
|
||
- Three tabs — "From Phrase" (`tab-mnemonic`), "From Key" (`tab-privkey`),
|
||
"From xprv" (`tab-xprv`) — each showing its own form section:
|
||
- **From Phrase**: instruction text, a die button that generates a
|
||
random recovery phrase, a recovery phrase textarea, and a backup
|
||
warning box that becomes visible once the die button has been used
|
||
- **From Key**: instruction text and a masked private key input
|
||
- **From xprv**: instruction text and a masked extended private key
|
||
input
|
||
- Password + confirm password inputs, with a hint line whose wording depends
|
||
on the selected tab
|
||
- "Import" button
|
||
- **Transitions**:
|
||
- "Import" with a valid entry and a matching password of at least 12
|
||
characters → creates the wallet, clears the navigation stack, and →
|
||
**Home**. The phrase and xprv modes then scan for further used addresses
|
||
and report the count as a flash message.
|
||
- "Import" with an invalid entry, a duplicate wallet or address, or a short
|
||
or mismatched password → flash message, no screen change
|
||
- "Back" → previous screen (Welcome, Home, or Settings)
|
||
|
||
#### AddressDetail (`address`)
|
||
|
||
- **When**: User tapped `[info]` on an address from Home.
|
||
- **Elements**:
|
||
- "Back" button
|
||
- Blockie identicon (48px, centered)
|
||
- Title: "Wallet Name — Address N"
|
||
- ENS name (if resolved, bold above the address)
|
||
- Full address (color dot, etherscan link, tap to copy)
|
||
- USD total for address
|
||
- Balance list: ETH + the ERC-20 tokens shown for this address (4 decimal
|
||
places, USD inline). Each balance row is clickable → **AddressToken**
|
||
- Send / Receive / + Token buttons and a "···" menu button
|
||
- "···" dropdown containing a single "Export Private Key" entry
|
||
- Transaction list (with ENS resolution for counterparties)
|
||
- **Transitions**:
|
||
- Tap balance row → **AddressToken** (for that token)
|
||
- "Send" → **Send** (refuses with a flash message on a zero balance)
|
||
- "Receive" → **Receive**
|
||
- "+ Token" → **AddToken**
|
||
- "···" → "Export Private Key" → **ExportPrivKey**
|
||
- Tap transaction row → **TransactionDetail**
|
||
- "Back" → previous screen (Home)
|
||
|
||
#### ExportPrivKey (`export-privkey`)
|
||
|
||
- **When**: User chose "Export Private Key" from the "···" menu on
|
||
AddressDetail. This screen discloses secret material.
|
||
- **Elements**:
|
||
- "Back" button
|
||
- Blockie identicon (48px, centered)
|
||
- "Export Private Key" heading
|
||
- "Wallet Name — Address N" and the full address (etherscan link, tap to
|
||
copy)
|
||
- Warning that anyone holding the private key can transfer all funds from
|
||
the address
|
||
- Error line
|
||
- Password input and "Reveal" button, shown until the key is revealed
|
||
- The private key on a highlighted background, tap to copy, shown only after
|
||
the password has been accepted
|
||
- **Transitions**:
|
||
- "Reveal" (correct password) → decrypts the wallet secret, derives this
|
||
address's key, hides the password input and shows the key (no screen
|
||
change)
|
||
- "Reveal" (wrong password) → full-sentence error on the error line, nothing
|
||
revealed (no screen change)
|
||
- "Back" → previous screen (AddressDetail)
|
||
- **Secret handling**: nothing is decrypted, no key is derived, and nothing is
|
||
written into the page until the password is accepted; the key is never stored
|
||
in state, and it is wiped from the page whenever the screen is left by any
|
||
route, including the Settings gear. A decrypt still running when the screen is
|
||
left is discarded rather than written. The screen is not restorable, so
|
||
reopening the popup lands on Home rather than back on the key.
|
||
|
||
#### AddressToken (`address-token`)
|
||
|
||
- **When**: User clicked a specific token balance on AddressDetail.
|
||
- **Elements**:
|
||
- "Back" button
|
||
- Blockie identicon (48px, centered)
|
||
- Title: "Wallet Name — Address N — TOKEN"
|
||
- Full address (color dot, etherscan link, tap to copy)
|
||
- USD total for this token
|
||
- Single token balance line (4 decimal places)
|
||
- Send / Receive buttons
|
||
- Token contract well (ERC-20 only): full contract address (tap to copy,
|
||
etherscan link) plus name, symbol, decimals, holder count and project
|
||
website where known
|
||
- Token-filtered transaction list (only this token's transfers)
|
||
- **Transitions**:
|
||
- "Send" → **Send** (token locked: the dropdown is replaced by a static
|
||
symbol and contract address)
|
||
- "Receive" → **Receive** (ERC-20 warning shown for non-ETH tokens)
|
||
- Tap transaction row → **TransactionDetail**
|
||
- "Back" → previous screen (AddressDetail)
|
||
|
||
#### Send (`send`)
|
||
|
||
- **When**: User wants to send ETH or a token, from Home, AddressDetail, or
|
||
AddressToken.
|
||
- **Elements**:
|
||
- "Back" button, "Send" heading
|
||
- From: address with color dot + etherscan link
|
||
- What to send: token dropdown (or static display with contract address when
|
||
locked from AddressToken)
|
||
- To: address or ENS name input, with an inline validation message
|
||
- Amount input with current balance display
|
||
- "Review" button, disabled until the recipient validates
|
||
- **Transitions**:
|
||
- "Review" (valid inputs, ENS resolved) → **ConfirmTx**
|
||
- "Review" with an unresolvable ENS name or an invalid amount → flash
|
||
message, no screen change
|
||
- "Back" → previous screen (Home, AddressDetail, or AddressToken)
|
||
|
||
#### ConfirmTx (`confirm-tx`)
|
||
|
||
- **When**: User reviewed send details and is ready to authorize.
|
||
- **Elements**:
|
||
- "Back" button, "Confirm Transaction" heading
|
||
- Type: "Native ETH transfer" or "ERC-20 token transfer (SYMBOL)"
|
||
- Token contract: full address + etherscan link (ERC-20 only)
|
||
- From: blockie + color dot + full address + etherscan link + wallet title
|
||
- To: blockie + color dot + full address + etherscan link + ENS name
|
||
- Amount: value + symbol (USD in parentheses)
|
||
- Your balance: value + symbol (USD in parentheses)
|
||
- Network fee: "Estimating..." then two lines, or "Unable to estimate",
|
||
fetched async. The first line is what the transfer is expected to cost,
|
||
`gasLimit * gasPrice` (USD in parentheses); the second is the
|
||
`gasLimit * maxFeePerGas` reserve the node requires, which is what the
|
||
balance check gates on. The second line is omitted on a network with no
|
||
type-2 pricing, where the two are the same number, but its space is
|
||
reserved either way
|
||
- Warnings: inline warnings from the local checks (scam address, self-send)
|
||
plus four reserved warning boxes made visible by the async checks —
|
||
recipient with no transaction history, recipient is a contract, burn
|
||
address, and an Etherscan phishing/scam label
|
||
- Errors (insufficient balance), plus three reserved error boxes — the
|
||
amount plus the fee exceeds the balance (ETH transfers), not enough ETH to
|
||
pay the fee for the transfer (ERC-20 transfers), and the fee could not be
|
||
estimated. The first two are mutually exclusive per transfer type, so only
|
||
the applicable one holds space
|
||
- Password: an inline field on this screen, not a modal, with its own error
|
||
line
|
||
- "Sign & Send" button (disabled if errors, and while the network fee
|
||
estimate is pending or unavailable)
|
||
- **Transitions**:
|
||
- "Sign & Send" (correct password) → broadcast tx → **WaitTx**
|
||
- "Sign & Send" (correct password) → broadcast fails → **ErrorTx**
|
||
- "Sign & Send" (wrong password) → "Wrong password." on the password error
|
||
line, no screen change
|
||
- "Back" → **Send**
|
||
|
||
#### WaitTx (`wait-tx`)
|
||
|
||
- **When**: Transaction has been broadcast, waiting for on-chain confirmation.
|
||
- **Elements**:
|
||
- "Transaction Broadcast" heading (no back button — tx is irreversible)
|
||
- Amount + symbol
|
||
- To: color dot + full address + etherscan link
|
||
- Transaction hash: full hash (tap to copy) + etherscan link
|
||
- Count-up timer: "Waiting for confirmation... Ns"
|
||
- **Behavior**: Polls `getTransactionReceipt` every 10 seconds. The wait is
|
||
persisted: closing and reopening the popup resumes the poll, with the elapsed
|
||
counter and the timeout deadline still measured from the original broadcast. A
|
||
lookup that fails is retried on the next tick rather than counted as a missing
|
||
receipt, because a failed lookup says nothing about the transaction; but six
|
||
failures in a row (60 seconds at the poll cadence) end the wait, so an RPC
|
||
that never answers cannot leave it running indefinitely. Any lookup that
|
||
answers resets that count.
|
||
- **Transitions**:
|
||
- Receipt found → **SuccessTx**
|
||
- A lookup that answers "no receipt" 60 seconds or more after broadcast →
|
||
**ErrorTx** (timeout message)
|
||
- Six consecutive failed lookups → **ErrorTx**, with a message naming the
|
||
unreachable network and pointing at the RPC URL in Settings. This is a
|
||
different fact from the timeout — the chain was never asked — and says so
|
||
- Exactly one outcome: a receipt found on the tick that crosses the deadline
|
||
wins, and no outcome can be rendered over another
|
||
|
||
#### SuccessTx (`success-tx`)
|
||
|
||
- **When**: Transaction confirmed on-chain.
|
||
- **Elements**:
|
||
- "Transaction Confirmed" heading
|
||
- Decoded action well (shown when the transaction carried recognized
|
||
calldata; the top-level Amount and To are hidden in that case)
|
||
- Amount + symbol
|
||
- To: color dot + full address + etherscan link
|
||
- Block number
|
||
- Transaction hash: full hash (tap to copy) + etherscan link
|
||
- "Done" button
|
||
- **Transitions**:
|
||
- "Done" in the approval popup → closes the popup window
|
||
- "Done" otherwise → resets the navigation stack, then → **AddressToken**
|
||
(if `selectedToken` set) or **AddressDetail**
|
||
|
||
#### ErrorTx (`error-tx`)
|
||
|
||
- **When**: Transaction broadcast failed, or timed out waiting for confirmation.
|
||
- **Elements**:
|
||
- "Transaction Failed" heading
|
||
- Amount + symbol
|
||
- To: color dot + full address + etherscan link
|
||
- Error message (dashed border box)
|
||
- Transaction hash section (hidden if broadcast failed before getting hash):
|
||
full hash (tap to copy) + etherscan link
|
||
- "Done" button
|
||
- **Transitions**:
|
||
- "Done" in the approval popup → closes the popup window
|
||
- "Done" otherwise → resets the navigation stack, then → **AddressToken**
|
||
(if `selectedToken` set) or **AddressDetail**
|
||
|
||
#### Receive (`receive`)
|
||
|
||
- **When**: User wants to receive funds at this address, from Home,
|
||
AddressDetail, or AddressToken.
|
||
- **Elements**:
|
||
- "Back" button, "Receive" heading
|
||
- Instruction text
|
||
- QR code encoding the address
|
||
- Full address (color dot, selectable, etherscan link)
|
||
- "Copy address" button
|
||
- ERC-20 warning (shown when navigating from AddressToken for non-ETH token)
|
||
- **Transitions**:
|
||
- "Back" → previous screen (Home, AddressDetail, or AddressToken)
|
||
|
||
#### TransactionDetail (`transaction`)
|
||
|
||
- **When**: User tapped a transaction row on Home, AddressDetail, or
|
||
AddressToken.
|
||
- **Elements** (grouped into logical blocks using light well containers; field
|
||
labels are self-explanatory so groups have no headings):
|
||
- "Transaction" heading, "Back" button
|
||
- Transaction hash: full hash (tap to copy) + etherscan link
|
||
- Type: transaction classification — one of: Native ETH Transfer, ERC-20
|
||
Token Transfer, Swap, Token Approval, Contract Call, Contract Creation
|
||
- Status: "Success" or "Failed"
|
||
- From: blockie + color dot + full address (tap to copy) + etherscan link;
|
||
ENS name if available
|
||
- To: blockie + color dot + full address (tap to copy) + etherscan link; ENS
|
||
name if available
|
||
- Time: ISO datetime + relative age in parentheses
|
||
- Block: block number (tap to copy) + etherscan block link
|
||
- Amount: value + symbol (bold)
|
||
- Native quantity: raw integer + unit (shown when available)
|
||
- Token contract: shown for ERC-20 transfers — color dot + full contract
|
||
address (tap to copy) + etherscan token link
|
||
- Decoded details (shown for contract calls): action name, decoded
|
||
parameters, token details, swap steps
|
||
- Network details (shown when on-chain data is available): nonce, gas price,
|
||
gas used, transaction fee (all tap to copy)
|
||
- Raw data (shown when calldata is present): full calldata in monospace
|
||
dashed border
|
||
- **Transitions**:
|
||
- "Back" → previous screen (Home, AddressDetail, or AddressToken)
|
||
|
||
#### AddToken (`add-token`)
|
||
|
||
- **When**: User wants to track an ERC-20 token, reached from "+ Token" on
|
||
AddressDetail.
|
||
- **Elements**:
|
||
- "Back" button, "Add Token" heading
|
||
- Instruction text (find contract address on Etherscan)
|
||
- Contract address input
|
||
- Status line ("Looking up token...", cleared or replaced on failure)
|
||
- Common token quick-pick buttons (top 25 by market cap), which fill the
|
||
contract address input
|
||
- "Add" button
|
||
- **Transitions**:
|
||
- "Add" (valid contract) → tracks the token, pops the stack, and re-renders
|
||
**AddressDetail**
|
||
- "Add" with a token already tracked, a scam-listed address, or a failed
|
||
contract lookup → flash message, no screen change
|
||
- "Back" → previous screen (AddressDetail)
|
||
|
||
#### Settings (`settings`)
|
||
|
||
- **When**: User tapped the Settings gear.
|
||
- **Elements**:
|
||
- "Back" button, "Settings" heading
|
||
- Wallets: one row per wallet with its name (tap to rename inline), a
|
||
`[recovery phrase]` button on HD wallets only, and an `[x]` delete button,
|
||
plus a "+ Add wallet" button
|
||
- Tracked Tokens: one row per tracked token with an `[x]` remove button,
|
||
plus a "+ Add token" button
|
||
- Display: "Show tracked tokens with zero balance" checkbox, "UTC
|
||
Timestamps" checkbox, and a Theme selector (System / Light / Dark)
|
||
- Network: network selector (Ethereum Mainnet / Sepolia Testnet); switching
|
||
resets the RPC and Blockscout endpoints to that network's defaults
|
||
- Ethereum RPC: endpoint URL input + "Save" button (validated against
|
||
`eth_chainId` before being saved)
|
||
- Blockscout API: endpoint URL input + "Save" button (validated against
|
||
`/stats` before being saved)
|
||
- Token Spam Protection:
|
||
- "Hide fake tokens impersonating a known symbol" checkbox
|
||
- "Hide tokens with fewer than 1,000 holders" checkbox
|
||
- "Hide transactions from detected fraud contracts" checkbox
|
||
- "Hide dust transactions below N gwei" checkbox + threshold input. The
|
||
threshold is plain decimal digits, a whole number of gwei, zero or
|
||
greater (zero hides nothing). Anything else — a fraction, a negative,
|
||
a value carrying its unit, hex (`0x10`) or exponent (`1e3`) notation —
|
||
is refused with a flash message and the field snaps back to the stored
|
||
threshold, so a number the user did not type is never stored.
|
||
- Allowed Sites: list with remove buttons
|
||
- Denied Sites: list with remove buttons
|
||
- About: project link, license, author, version, release date, and the
|
||
commit, which links to the commit in the repository
|
||
- Debug: hidden until revealed, then an "Enable debug mode" checkbox that
|
||
turns on the red banner and verbose logging
|
||
- **Transitions**:
|
||
- "+ Add wallet" → **AddWallet**
|
||
- "+ Add token" → **SettingsAddToken**
|
||
- `[recovery phrase]` on an HD wallet → **ShowRecoveryPhrase**
|
||
- `[x]` on a wallet → **DeleteWallet**
|
||
- Tap wallet name → inline rename field (no screen change)
|
||
- `[x]` on a tracked token or a site → removes it in place (no screen
|
||
change)
|
||
- Ten clicks on the version → reveals the Debug well (no screen change)
|
||
- "Back" (or Settings gear again) → previous screen (Home)
|
||
|
||
#### ShowRecoveryPhrase (`show-phrase`)
|
||
|
||
- **When**: User tapped `[recovery phrase]` on a wallet row in Settings. HD
|
||
wallets only: key and xprv wallets have no recovery phrase, so their rows do
|
||
not offer the action at all.
|
||
- **Elements**:
|
||
- "Back" button, "Recovery Phrase" heading
|
||
- Wallet name
|
||
- Warning box stating that anyone holding these words can take everything in
|
||
the wallet, from any device, without the password
|
||
- Error line
|
||
- Password input + "Reveal" button, shown until the password is accepted
|
||
- The recovery phrase itself, in full and click-to-copy, shown only after a
|
||
correct password and in place of the password prompt
|
||
- **Transitions**:
|
||
- "Reveal" (correct password) → the phrase replaces the password prompt (no
|
||
screen change)
|
||
- "Reveal" (wrong password) → full-sentence error, nothing revealed (no
|
||
screen change)
|
||
- "Back" → previous screen (Settings)
|
||
- **Secret handling**: nothing is decrypted or written into the page until the
|
||
password is accepted; the phrase is never stored in state, and it is wiped
|
||
from the page whenever the screen is left by any route, including the Settings
|
||
gear. A decrypt still running when the screen is left is discarded rather than
|
||
written. The screen is not restorable, so reopening the popup lands on Home
|
||
rather than back on the phrase.
|
||
|
||
#### DeleteWallet (`delete-wallet-confirm`)
|
||
|
||
- **When**: User tapped the `[x]` next to a wallet in Settings.
|
||
- **Elements**:
|
||
- "Back" button, "Delete Wallet" heading
|
||
- Warning naming the wallet and stating that deletion is permanent and any
|
||
funds are unrecoverable without the recovery phrase
|
||
- Error line
|
||
- Password input
|
||
- "Confirm Delete" button
|
||
- **Transitions**:
|
||
- "Confirm Delete" (correct password, other wallets remain) → deletes the
|
||
wallet and its site permissions, then → **Settings** with a "Wallet
|
||
deleted." flash message
|
||
- "Confirm Delete" (correct password, last wallet) → deletes the wallet,
|
||
clears the selection and the navigation stack, then → **Welcome**
|
||
- Either way, the active address moves only if it belonged to the deleted
|
||
wallet, and `AUTISTMASK_ACTIVE_CHANGED` is broadcast when it does
|
||
(`src/shared/walletDelete.js`)
|
||
- "Confirm Delete" (wrong password) → "Wrong password." on the error line,
|
||
nothing deleted
|
||
- "Back" → previous screen (Settings)
|
||
|
||
#### DeleteAddress (`delete-address-confirm`)
|
||
|
||
- **When**: User tapped the `[x]` next to an address on Home. Offered only on HD
|
||
and xprv wallets holding more than one address: the last address of a wallet
|
||
is never removable, and a key wallet has exactly one.
|
||
- **Elements**:
|
||
- "Back" button, "Remove Address" heading
|
||
- The address's own label ("Address N") and its wallet's name
|
||
- The full address (color dot, etherscan link, tap to copy), with the ENS
|
||
name above it if resolved
|
||
- Explanation that this only stops the wallet tracking the address: nothing
|
||
is destroyed, no key is deleted, and funds stay where they are
|
||
- The route back, stated with its limit, because the obvious two are both
|
||
refused: "+" derives the next unused index (`nextIndex` is a high-water
|
||
mark), and re-importing the wallet's key material is rejected as a
|
||
duplicate by `findWalletByXpub` while the wallet is still present. What
|
||
works is deleting the whole wallet in Settings — password-gated, and it
|
||
destroys the stored secret — then importing again, whereupon
|
||
`scanForAddresses()` rediscovers the address **only if it has on-chain
|
||
activity**. An address that was never used is not found by that scan. The
|
||
text is written by `recoveryPathText()` rather than sitting in
|
||
`index.html`, so it can name the wallet's own kind of key material: an
|
||
xprv wallet has no recovery phrase to re-import.
|
||
- A warning when the address holds anything, ETH or any tracked ERC-20,
|
||
followed by the holdings themselves via `balanceLinesForAddress()` and the
|
||
USD total via `getAddressValueUsd()`. The sentence names no figure of its
|
||
own: the lines round to four decimals, so a sentence built from a rounded
|
||
number would report `0.0000 ETH` for an address holding real money. The
|
||
predicate is `addressHoldsFunds()` in `src/popup/views/helpers.js`,
|
||
unrounded and token-aware. A balance is a warning, never a refusal.
|
||
- The rule that a wallet always keeps at least one address, and that
|
||
removing the last one means deleting the wallet from Settings
|
||
- Error line
|
||
- "Remove Address" button
|
||
- **Transitions**:
|
||
- "Remove Address" → removes the address and its site permissions, then →
|
||
previous screen (Home) with an "Address removed." flash message
|
||
- "Back" → previous screen (Home), nothing removed
|
||
- **Deliberately not password-gated**, unlike DeleteWallet: a password gates the
|
||
disclosure or destruction of a secret, and this does neither. The address
|
||
stays derivable from key material the wallet still holds.
|
||
- The active address moves only if it was the address removed, and then to the
|
||
wallet's first remaining address, with `AUTISTMASK_ACTIVE_CHANGED` broadcast
|
||
so a connected site stops being told about an address the user removed
|
||
(`src/shared/walletDelete.js`). A selection in any other wallet is left alone;
|
||
one in this wallet follows the splice.
|
||
- The wallet's derivation counter (`nextIndex`) is not rewound, so "+" derives a
|
||
fresh address rather than handing back the one just removed.
|
||
|
||
#### SettingsAddToken (`settings-addtoken`)
|
||
|
||
- **When**: User tapped "+ Add token" in Settings. Tokens added here are tracked
|
||
across every address, unlike AddToken which is reached from one address.
|
||
- **Elements**:
|
||
- "Back" button, "Add Token" heading
|
||
- Instruction text
|
||
- "Top tokens:" quick-pick buttons (top 10 by market cap; already-tracked
|
||
tokens are disabled)
|
||
- "Or pick from top 100:" dropdown (already-tracked tokens are disabled) +
|
||
"Add selected" button
|
||
- "Or enter contract address:" input, a status line, and an "Add" button
|
||
- **Transitions**:
|
||
- Any of the three add paths, on success → adds the token and shows an
|
||
"Added SYMBOL" flash message (no screen change)
|
||
- A duplicate, a scam-listed address, or a failed contract lookup → flash
|
||
message, no screen change
|
||
- "Back" → previous screen (Settings)
|
||
|
||
#### SiteApproval (`approve-site`)
|
||
|
||
- **When**: A website requests wallet access via `eth_requestAccounts` or
|
||
`wallet_requestPermissions` and is on neither the allowed nor the denied list.
|
||
The background script prefers the toolbar popup (`action.openPopup()`) and
|
||
falls back to a separate popup window (`src/background/index.js`,
|
||
`requestApproval()`).
|
||
- **Elements**:
|
||
- "Connection Request" heading
|
||
- Phishing warning banner (shown when the hostname is on the phishing
|
||
blocklist)
|
||
- Site hostname (bold) + "wants to connect to your wallet"
|
||
- Address that will be shared (color dot + full address + etherscan link)
|
||
- "Remember my choice for this site" checkbox
|
||
- "Allow" / "Deny" buttons
|
||
- **Transitions**:
|
||
- "Allow" / "Deny" → closes popup (returns result to background script; the
|
||
choice is persisted to the allowed or denied list when "Remember" is
|
||
checked)
|
||
- Popup closed without answering → treated as a denial
|
||
|
||
#### TxApproval (`approve-tx`)
|
||
|
||
- **When**: A connected website requests a transaction via
|
||
`eth_sendTransaction`. Always opened in a separate popup window by the
|
||
background script (`windows.create()`), because the request is triggered
|
||
programmatically rather than by a user gesture. The background populates the
|
||
transaction (nonce, gas limit, fees, chain id) against the RPC node _before_
|
||
opening the window, so the screen shows a complete transaction and the signed
|
||
artifact can be compared with it field for field. A request that cannot be
|
||
populated — unreachable node, reverting gas estimate — opens no window and is
|
||
failed back to the site.
|
||
- **Elements**:
|
||
- "Transaction Request" heading
|
||
- Phishing warning banner (shown when the hostname is on the phishing
|
||
blocklist)
|
||
- Site hostname (bold) + "wants to send a transaction"
|
||
- Decoded action (if calldata is recognized): action name, token details,
|
||
amounts, steps, deadline (see Transaction Decoding)
|
||
- From: color dot + full address + etherscan link
|
||
- Contract: color dot + full address + etherscan link (or "contract
|
||
creation"), token symbol label if known
|
||
- Value: amount in ETH (4 decimal places, USD in parentheses)
|
||
- Network fee (max): gas limit × fee per gas in ETH (4 decimal places, USD
|
||
in parentheses), with the gas limit and the fee per gas in gwei below it
|
||
- Network and nonce
|
||
- Raw data: full calldata displayed inline (shown if present)
|
||
- Password input and an error line
|
||
- "Confirm" / "Reject" buttons
|
||
- **Transitions**:
|
||
- "Confirm" (correct password) → decrypts and signs the transaction it was
|
||
shown, exactly as shown, hands the signed transaction to the background to
|
||
broadcast, then → **WaitTx** in the same popup window
|
||
- "Confirm" (wrong password) → error line, no screen change
|
||
- "Reject" → closes popup (returns rejection to background)
|
||
- Popup window closed without answering → the request is rejected with
|
||
EIP-1193 code 4001
|
||
|
||
#### SignApproval (`approve-sign`)
|
||
|
||
- **When**: A connected website requests a message signature via
|
||
`personal_sign`, `eth_sign`, or `eth_signTypedData_v4`. Opened the same way as
|
||
TxApproval, in a separate popup window.
|
||
- **Elements**:
|
||
- "Signature Request" heading
|
||
- Phishing warning banner (shown when the hostname is on the phishing
|
||
blocklist)
|
||
- Site hostname (bold) + "wants you to sign a message"
|
||
- Danger warning box (shown for `eth_sign`, which signs a raw hash)
|
||
- Type: "Personal message" or "Typed data (EIP-712)"
|
||
- From: color dot + full address + etherscan link
|
||
- Message: decoded UTF-8 text (personal_sign) or formatted domain/type/
|
||
message fields (EIP-712 typed data)
|
||
- Password input and an error line
|
||
- "Sign" / "Reject" buttons
|
||
- **Transitions**:
|
||
- "Sign" (correct password) → signs locally → closes popup (returns
|
||
signature)
|
||
- "Sign" (wrong password, or a signing failure) → error line, no screen
|
||
change
|
||
- "Reject" → closes popup (returns rejection to background)
|
||
- Popup window closed without answering → the request is rejected with
|
||
EIP-1193 code 4001
|
||
|
||
### External Services
|
||
|
||
AutistMask is not a fully self-contained offline tool. It necessarily
|
||
communicates with three external services to function as a wallet:
|
||
|
||
- **Ethereum JSON-RPC endpoint**: The extension needs an Ethereum node to query
|
||
balances (`eth_getBalance`), read ERC-20 token contracts (`eth_call`),
|
||
estimate gas (`eth_estimateGas`), fetch nonces (`eth_getTransactionCount`),
|
||
broadcast transactions (`eth_sendRawTransaction`), and check transaction
|
||
receipts. The default endpoint is a public RPC (configurable by the user to
|
||
any endpoint they prefer, including a local node). By default the extension
|
||
talks to `https://ethereum-rpc.publicnode.com`.
|
||
- **Data sent**: Ethereum addresses, transaction data, contract call
|
||
parameters. The RPC endpoint can see all on-chain queries and submitted
|
||
transactions.
|
||
|
||
- **CoinDesk CADLI price API**: Used to fetch ETH/USD and token/USD prices for
|
||
displaying fiat values. The price is cached for 5 minutes to avoid excessive
|
||
requests. No API key required. No user data is sent — only a list of token
|
||
symbols. Note that CoinDesk will receive your client IP.
|
||
- **Data sent**: Token symbol strings only (e.g. "ETH", "USDC"). No
|
||
addresses or user-specific data.
|
||
|
||
- **Blockscout block-explorer API**: Used to fetch transaction history (normal
|
||
transactions and ERC-20 token transfers), ERC-20 token balances, and token
|
||
holder counts (for spam filtering). The default endpoint is
|
||
`https://eth.blockscout.com/api/v2` (configurable by the user in Settings).
|
||
- **Data sent**: Ethereum addresses. Blockscout receives the user's
|
||
addresses to query their transaction history and token balances. No
|
||
private keys, passwords, or signing operations are sent.
|
||
|
||
What the extension does NOT do:
|
||
|
||
- No analytics or telemetry services
|
||
- No token list APIs (the known-token list is bundled at build time)
|
||
- No Infura/Alchemy dependency (any JSON-RPC endpoint works)
|
||
- No backend servers operated by the developer
|
||
|
||
In addition to the three user-configurable services above (RPC endpoint,
|
||
CoinDesk price API, and Blockscout API), AutistMask also contacts:
|
||
|
||
- **Phishing domain blocklist**: A community-maintained phishing domain
|
||
blocklist is vendored into the extension at build time. At runtime, the
|
||
extension fetches the live list once every 24 hours to detect newly added
|
||
domains, plus once on a start where the list is more than 24 hours old. Only
|
||
the delta (domains not already in the vendored list) is kept in memory,
|
||
keeping runtime memory usage small. The delta and the timestamp of the fetch
|
||
that produced it are persisted to extension storage if the record is under 256
|
||
KiB; an oversized delta is dropped along with its timestamp, so a later start
|
||
fetches again rather than claiming freshness for data it no longer holds. A
|
||
fetch that fails, or one whose delta was too large to store, is not retried
|
||
more than once an hour outside the 24-hour schedule.
|
||
- **Etherscan address labels**: When confirming a transaction, the extension
|
||
performs a best-effort lookup of the recipient address on Etherscan to check
|
||
for phishing/scam labels. This is a direct page fetch with no API key; the
|
||
user's browser makes the request.
|
||
|
||
Users who want maximum privacy can point the RPC and Blockscout URLs at their
|
||
own self-hosted instances (price fetching can be disabled in a future version).
|
||
|
||
### Dependencies
|
||
|
||
AutistMask uses four runtime libraries. All cryptographic operations are
|
||
delegated to ethers and libsodium — see the Crypto Policy section below.
|
||
|
||
| Package | Version | License | Purpose |
|
||
| -------------------------- | ------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||
| `ethers` | 6.16.0 | MIT | All Ethereum operations: BIP-39 mnemonic generation/validation, BIP-32/BIP-44 HD key derivation (`m/44'/60'/0'/0/n`), secp256k1 signing, transaction construction, ERC-20 contract interaction, JSON-RPC communication, address derivation (keccak256). |
|
||
| `libsodium-wrappers-sumo` | 0.8.2 | ISC | Password-based encryption of secrets at rest: Argon2id key derivation (`crypto_pwhash`), authenticated encryption (`crypto_secretbox` / XSalsa20-Poly1305). |
|
||
| `qrcode` | 1.5.4 | MIT | QR code generation for the Receive screen (renders address as scannable QR on canvas). |
|
||
| `ethereum-blockies-base64` | 1.0.2 | ISC | Deterministic pixelated identicon generation from Ethereum addresses (same style used by Etherscan). |
|
||
|
||
Dev dependencies (not shipped in extension):
|
||
|
||
| Package | Version | License | Purpose |
|
||
| ------------------ | ------- | ------- | ------------------------- |
|
||
| `esbuild` | 0.27.3 | MIT | JS bundler (inlines deps) |
|
||
| `tailwindcss` | 4.2.1 | MIT | CSS compilation |
|
||
| `@tailwindcss/cli` | 4.2.1 | MIT | Tailwind CLI |
|
||
| `jest` | 30.2.0 | MIT | Test runner |
|
||
| `prettier` | 3.8.1 | MIT | Code formatter |
|
||
|
||
### Crypto Policy
|
||
|
||
**No raw crypto primitives in application code.** If the strings `aes`, `sha`,
|
||
`pbkdf`, `hmac`, `encrypt`, `decrypt`, `hash`, `cipher`, `digest`, `sign`
|
||
(case-insensitive) appear in our own source code (outside of `node_modules/`),
|
||
it is almost certainly a bug. All cryptographic operations must go through
|
||
`ethers` or `libsodium-wrappers-sumo`. Both are widely audited and
|
||
battle-tested.
|
||
|
||
Exceptions require explicit authorization in a code comment referencing this
|
||
policy, but as of now there are none.
|
||
|
||
### Content Security Policy
|
||
|
||
Both manifests declare the same policy for extension pages —
|
||
`script-src 'self' 'wasm-unsafe-eval'; object-src 'self'` — as an object under
|
||
`content_security_policy.extension_pages` in `manifest/chrome.json` (MV3) and as
|
||
a bare string in `manifest/firefox.json` (MV2).
|
||
|
||
`'wasm-unsafe-eval'` is there for one reason: libsodium. It ships a WebAssembly
|
||
build and a `wasm2js` translation of it in one file, tries WASM first, and
|
||
silently falls back to the translation if instantiation throws. Under a plain
|
||
`script-src 'self'` the fallback was taken on every popup load, announced by
|
||
nothing but an uncaught `CompileError`. Measured on the same Argon2id parameters
|
||
the vault uses (`OPSLIMIT_INTERACTIVE`, `MEMLIMIT_INTERACTIVE`), WASM derives a
|
||
key in 141-198ms and `wasm2js` in 3204-3660ms. The work factor is identical — it
|
||
is set by the ops and memory parameters, not by wall time — so the fallback
|
||
bought nothing and cost about three and a half seconds on every operation that
|
||
asks for the password, which is every signature.
|
||
|
||
The keyword permits compiling WebAssembly and nothing else: not `eval()` of
|
||
strings, not inline script, not remote script. Using it requires already
|
||
executing script in an extension page, which is complete compromise on its own.
|
||
`'unsafe-eval'` is a different proposition and is not granted.
|
||
|
||
The grant is pinned in both directions. `tests/manifest.test.js` asserts the
|
||
exact token set in both manifests, so dropping `'wasm-unsafe-eval'` (a silent
|
||
20x regression on the key derivation) and adding anything beyond it both fail
|
||
`make check`. `tests/vaultBackend.test.js` asserts the unit tests run the WASM
|
||
backend, and `make test-e2e` compiles a WebAssembly module inside the real popup
|
||
under the real manifest.
|
||
|
||
### DEBUG Mode Policy
|
||
|
||
The `DEBUG` constant in the popup JS enables a red "DEBUG / INSECURE" banner and
|
||
a hardcoded test mnemonic. **DEBUG mode must behave as close to normal mode as
|
||
possible.** No `if (DEBUG)` branches that skip functionality, bypass security
|
||
flows, or alter program behavior beyond the banner and the hardcoded mnemonic.
|
||
Adding new DEBUG-conditional branches requires explicit approval from the
|
||
project owner.
|
||
|
||
`DEBUG` is a build-time constant, not a runtime setting. `build.js` injects it
|
||
into the bundle as the `__BUILD_DEBUG__` define — `false` unless the build was
|
||
run with `AUTISTMASK_DEBUG=1` (see [Debug Builds](#debug-builds)) — and
|
||
`src/shared/constants.js` reads it. It cannot be changed after the bundle is
|
||
produced.
|
||
|
||
The debug-mode toggle in settings is a separate, runtime-only flag. It raises
|
||
the log level and turns the banner on, and that is all it may ever do: it feeds
|
||
`isDebug()` in `src/shared/log.js`, which is deliberately not what
|
||
`generateMnemonic()` consults. Mnemonic generation reads the build-time `DEBUG`
|
||
constant directly, so no runtime toggle in a release build can reach the
|
||
hardcoded test phrase.
|
||
|
||
### Key Decisions
|
||
|
||
- **No framework**: The popup UI is vanilla JS and HTML. The extension is small
|
||
enough that a framework adds unnecessary complexity and attack surface.
|
||
- **Split storage model**: Public data (xpubs, derived addresses, token lists,
|
||
balances) is stored unencrypted in extension local storage so the user can
|
||
view their wallets and balances at any time without entering a password.
|
||
Private data (recovery phrases, private keys) will be encrypted at rest using
|
||
libsodium — a password is only required when the user needs to sign a
|
||
transaction or message. The encryption scheme for private data:
|
||
- The user's password is run through Argon2id (`crypto_pwhash`) to derive a
|
||
256-bit encryption key. Argon2id is memory-hard, making GPU/ASIC brute
|
||
force attacks expensive.
|
||
- The derived key encrypts the secret material using XSalsa20-Poly1305
|
||
(`crypto_secretbox`), which provides authenticated encryption (the
|
||
ciphertext cannot be tampered with without detection).
|
||
- Stored blob: `{ salt, nonce, ciphertext }` (the auth tag is part of the
|
||
`crypto_secretbox` output).
|
||
- **The password is NOT used in address derivation.** It exists solely to
|
||
protect the recovery phrase / private key on disk. Anyone with the
|
||
recovery phrase can restore the wallet on any device without this
|
||
password. This matches standard EVM wallet behavior.
|
||
- **BIP-39 / BIP-44 via ethers.js**: Mnemonic generation, validation, and HD key
|
||
derivation (`m/44'/60'/0'/0/n`) are handled entirely by ethers.js. The BIP-39
|
||
passphrase is always empty (matching most wallet software). The user's
|
||
password is completely separate and has no effect on which addresses are
|
||
generated.
|
||
- **ethers.js for everything Ethereum**: Transaction construction, signing, gas
|
||
estimation, RPC communication, ERC-20 contract calls, and address derivation
|
||
are all handled by ethers.js. This means zero hand-rolled Ethereum logic.
|
||
- **EIP-1193 provider**: The content script injects a `window.ethereum` object
|
||
that implements the EIP-1193 provider interface, enabling web3 site
|
||
connectivity.
|
||
- **Minimal RPC**: The extension communicates with Ethereum nodes via JSON-RPC
|
||
through ethers.js's `JsonRpcProvider`. The default endpoint is configurable.
|
||
No Infura dependency — users can point it at any Ethereum JSON-RPC endpoint.
|
||
|
||
### Supported Functionality
|
||
|
||
- Create new HD wallet (generates 12-word recovery phrase)
|
||
- Import HD wallet from existing 12 or 24 word recovery phrase
|
||
- Import single-address wallet from private key
|
||
- Import multi-address wallet from an extended private key (`xprv`)
|
||
- Add multiple addresses within an HD wallet
|
||
- Manage multiple wallets simultaneously
|
||
- View ETH balance per address
|
||
- View ERC-20 token balances (tokens on the bundled known-token list, tokens
|
||
with 1,000 or more holders, and tokens the user adds by contract address)
|
||
- Send ETH to an address
|
||
- Send ERC-20 tokens to an address
|
||
- Receive ETH/tokens (display address, copy to clipboard, QR code)
|
||
- Connect to web3 sites (EIP-1193 `eth_requestAccounts`)
|
||
- Sign transactions requested by connected sites (`eth_sendTransaction`)
|
||
- Sign messages (`personal_sign`, `eth_sign`)
|
||
- Sign typed data (`eth_signTypedData_v4`, `eth_signTypedData`)
|
||
- Human-readable transaction decoding (ERC-20, Uniswap Universal Router)
|
||
- ETH/USD and token/USD price display
|
||
- Configurable RPC endpoint and Blockscout API
|
||
- Address poisoning protection (spam token filtering, dust filtering, fraud
|
||
contract blocklist)
|
||
|
||
### Address Poisoning and Fake Token Transfer Attacks
|
||
|
||
During development, one of our test addresses
|
||
(`0x66133E8ea0f5D1d612D2502a968757D1048c214a`) sent 0.005 ETH to
|
||
`0xC3c693Ae04BaD5f13C45885C1e85a9557798f37E`. Within seconds, a fraudulent
|
||
transaction appeared in the address's token transfer history
|
||
(`0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84`) showing a
|
||
0.005 "ETH" transfer from our address to
|
||
`0xC3C0AEA127c575B9FFD03BF11C6a878e8979c37F` — a scam address whose first four
|
||
characters (`0xC3C0`) visually resemble the legitimate recipient (`0xC3c6`).
|
||
|
||
**How it works:** A scammer deploys a malicious ERC-20 contract (in this case,
|
||
`0xD05339f9Ea5ab9d9F03B9d57F671d2abD1F55c82`, a fake token calling itself
|
||
"Ethereum" with symbol "ETH" and zero holders). This contract has a function
|
||
that emits an ERC-20 `Transfer(from, to, amount)` event with arbitrary
|
||
parameters. The EVM does not enforce that the `from` address in a Transfer event
|
||
actually initiated or authorized the transfer — any contract can emit any event
|
||
with any parameters. The scammer calls their contract, which emits a Transfer
|
||
event claiming the victim sent tokens to the scam address. Every blockchain
|
||
indexer (Blockscout, Etherscan, etc.) sees a valid Transfer event log and
|
||
indexes it as a real token transfer.
|
||
|
||
**The attack has two goals:**
|
||
|
||
1. **Autocomplete poisoning**: Wallets that offer address autocomplete based on
|
||
transaction history will suggest the scam address (which looks similar to a
|
||
legitimate recent recipient) when the user starts typing. The user copies the
|
||
wrong address and sends real funds to the scammer.
|
||
|
||
2. **Transaction history confusion**: The fake transfer appears in the victim's
|
||
history as an outbound transaction, making it look like the user sent funds
|
||
to the scam address. Users who copy-paste addresses from their own
|
||
transaction history may grab the wrong one.
|
||
|
||
**What AutistMask does about it:**
|
||
|
||
- **Minimal, careful truncation**: Where space constraints require truncation
|
||
(e.g. the transaction history list), AutistMask truncates conservatively —
|
||
displaying enough characters that generating a vanity address matching the
|
||
visible portion is computationally infeasible. All confirmation screens
|
||
(transaction signing, send confirmation) display the complete untruncated
|
||
address. Users should always verify the full address on the confirmation
|
||
screen before signing or sending.
|
||
|
||
- **Known token symbol verification**: AutistMask ships a hardcoded list of
|
||
high-market-cap ERC-20 tokens with their legitimate contract addresses and
|
||
symbols. The list is a point-in-time snapshot of the highest-market-cap
|
||
Ethereum mainnet ERC-20s taken from the CoinGecko API, with decimals verified
|
||
on-chain and addresses EIP-55 checksummed; `TOKENS` in
|
||
`src/shared/tokenList.js` is the authoritative set. It is bundled at build
|
||
time and only changes when that file is regenerated. Any token transfer
|
||
claiming a symbol from this list (e.g. "ETH", "USDT", "USDC") but originating
|
||
from an unrecognized contract address is identified as a spoof and filtered
|
||
from display. The fake "Ethereum" token in the attack above used symbol "ETH"
|
||
from contract `0xD05339f9Ea5ab9d9F03B9d57F671d2abD1F55c82`, which does not
|
||
match the known WETH contract — so it would be caught by this check. Detecting
|
||
a spoof is also what adds a contract to the fraud contract blocklist below;
|
||
that is the only thing that populates it. In the transaction history the check
|
||
is the "Hide fake tokens impersonating a known symbol" setting, on by default;
|
||
with it off, spoofed transfers are shown and no new blocklist entries are
|
||
learned from them. The send-screen token selector and the balance list apply
|
||
the same check unconditionally, because they decide which tokens the user can
|
||
act on and what the user believes they own rather than what the history
|
||
displays. All three surfaces read the rule from `src/shared/symbolSpoof.js`,
|
||
so they cannot answer the question differently. A symbol the list maps to no
|
||
contract at all — `"ETH"`, the native asset, is the only one — may be borne by
|
||
no contract, so every ERC-20 claiming it is a spoof on all three. The user's
|
||
real ETH balance is not an ERC-20 and is read over RPC, so the rule never sees
|
||
it.
|
||
|
||
- **Low-holder token filtering**: Token transfers from ERC-20 contracts with
|
||
fewer than 1,000 holders are hidden from transaction history by default.
|
||
Legitimate tokens have substantial holder counts; poisoning tokens typically
|
||
have zero. This catches new poisoning contracts that use novel symbols not in
|
||
the known token list.
|
||
|
||
- **Fraud contract blocklist**: AutistMask maintains a local list of known fraud
|
||
contract addresses. Token transfers involving these contracts are filtered
|
||
from the transaction history display. The list is populated when a fraudulent
|
||
transfer is detected and persists across sessions.
|
||
|
||
- **Send-side token filtering**: Tokens with fewer than 1,000 holders are
|
||
excluded from the token selector on the send screen. This prevents users from
|
||
accidentally interacting with a spoofed token that appeared in their balance
|
||
via a fake Transfer event.
|
||
|
||
- **Dust transaction filtering**: A second wave of the same attack used real
|
||
native ETH transfers instead of fake tokens. Transaction
|
||
`0x2708ebddfb9b5fa3f7a89d3ea398ef9fd8771b83ed861ecb7c21cd55d18edc74` sent 1
|
||
gwei (0.000000001 ETH) from `0xC3c6B3b4402bD78A9582aB6b00E747769344F37E` —
|
||
another look-alike of the legitimate recipient `0xC3c693...`. Because this is
|
||
a real ETH transfer (not a fake token), none of the token-level filters catch
|
||
it. AutistMask hides transactions below a configurable dust threshold
|
||
(default: 100,000 gwei / 0.0001 ETH). This is high enough to filter poisoning
|
||
dust while low enough to preserve any transfer a user would plausibly care
|
||
about. The threshold is user-configurable in Settings; a threshold of `0`
|
||
hides nothing, exactly as clearing the checkbox does.
|
||
|
||
- **User-configurable**: All four filters (known symbol verification, low-holder
|
||
threshold, fraud contract blocklist, dust threshold) are settings that default
|
||
to on but can be individually disabled by the user. AutistMask is designed as
|
||
a sharp tool — users who understand the risks can configure the wallet to show
|
||
everything unfiltered, unix-style. All four settings govern the transaction
|
||
history; what else each one reaches varies. The known-symbol check also runs
|
||
unconditionally on the send-screen token selector and on the balance list, in
|
||
both cases identically to the history. The fraud contract blocklist is applied
|
||
unconditionally on that selector and is not consulted by the balance list at
|
||
all. The low-holder setting also gates the send selector, while the balance
|
||
list's own 1,000-holder floor is unconditional (see Data Model). The dust
|
||
threshold applies to the transaction history alone.
|
||
|
||
#### Phishing Domain Protection
|
||
|
||
AutistMask protects users from known phishing sites when they connect their
|
||
wallet or approve transactions/signatures. A community-maintained domain
|
||
blocklist is vendored into the extension at build time, providing immediate
|
||
protection without any network requests. At runtime, the extension fetches the
|
||
live list once every 24 hours and keeps only the delta (newly added domains not
|
||
in the vendored list) in memory. This architecture keeps runtime memory usage
|
||
small while ensuring fresh coverage of new phishing domains.
|
||
|
||
The 24-hour cadence is an alarm, not a timer; the alarm tick fetches
|
||
unconditionally rather than re-checking the 24-hour cache TTL that gates the
|
||
startup path; and the fetch timestamps live in extension storage rather than in
|
||
module variables — see [Background scheduling](#background-scheduling) for why
|
||
all three are required.
|
||
|
||
When a dApp on a blocklisted domain requests a wallet connection, transaction
|
||
approval, or signature, the approval popup displays a prominent red warning
|
||
banner alerting the user. The domain checker matches exact hostnames and all
|
||
parent domains (subdomain matching).
|
||
|
||
#### Transaction Decoding
|
||
|
||
When a dApp asks the user to approve a transaction, AutistMask attempts to
|
||
decode the calldata into a human-readable summary. This is purely a display
|
||
convenience to help the user understand what they are signing — it is not
|
||
endorsement, special treatment, or partnership with any protocol.
|
||
|
||
AutistMask is a generic web3 wallet. It treats all dApps, protocols, and
|
||
contracts equally. No contract gets special handling, priority, or integration
|
||
beyond what is needed to show the user a legible confirmation screen. Our
|
||
commitment is to the user, not to any service, site, or contract.
|
||
|
||
Decoded transaction summaries are best-effort. If decoding fails, the raw
|
||
calldata is displayed in full. The decoders live in self-contained modules under
|
||
`src/shared/` (e.g. `uniswap.js`) so they can be added for common contracts
|
||
without polluting wallet-specific code. Contributions of decoders for other
|
||
widely-used contracts are welcome.
|
||
|
||
Currently supported:
|
||
|
||
- **ERC-20**: `approve()` and `transfer()` calls — shows token symbol, spender
|
||
or recipient, and amount.
|
||
- **Uniswap Universal Router**: `execute()` calls — shows swap direction (e.g.
|
||
"Swap USDT → ETH"), token addresses, amounts, execution steps, and deadline.
|
||
Decodes Permit2, V2/V3/V4 swaps, wrap/unwrap, and balance checks.
|
||
|
||
### Non-Goals Forever
|
||
|
||
- Built in token swaps (use a DEX in the browser)
|
||
- Analytics, telemetry, or tracking of any kind
|
||
- Advertisements or promotions
|
||
- Obscure token list auto-discovery — nothing outside the bundled list, the
|
||
1,000-holder floor, and the tokens the user added by contract address
|
||
- We detect common/popular ERC20s in the basic case
|
||
- Fiat on/off ramps
|
||
- Extensive transaction decoding/parsing
|
||
- For common ones we will do best-effort, but you should just use a block
|
||
explorer.
|
||
|
||
### Non-Goals for 1.0
|
||
|
||
- Multi-chain support (Ethereum mainnet only)
|
||
- Hardware wallet support
|
||
|
||
## TODO
|
||
|
||
### Wallet Management
|
||
|
||
- [x] Delete wallet (with confirmation)
|
||
- [x] Delete address from HD wallet (with confirmation)
|
||
- [x] Show wallet's recovery phrase (requires password)
|
||
|
||
### Transactions
|
||
|
||
- [x] Gas estimation and fee display before confirming
|
||
|
||
### Testing
|
||
|
||
- [x] Tests for mnemonic generation and address derivation
|
||
- [x] Tests for xpub derivation and child address generation
|
||
- [ ] Test on Firefox (Manifest V2)
|
||
|
||
### Scam List
|
||
|
||
- [ ] Research and document each address in scamlist.js
|
||
- [ ] Add more known fraud addresses from Etherscan labels
|
||
|
||
### Future
|
||
|
||
- [ ] Multi-currency fiat display (EUR, GBP, etc.)
|
||
- [ ] Security audit of key management
|
||
|
||
## Policies
|
||
|
||
- We don't mention "the other wallet" by name in code or documentation. We're
|
||
our own thing.
|
||
- The README is the complete authoritative technical documentation. It's ok if
|
||
it gets big.
|
||
|
||
## License
|
||
|
||
GPL-3.0. See [LICENSE](LICENSE).
|
||
|
||
### Third-Party Data Files
|
||
|
||
This repository includes data files from third-party projects that are not
|
||
covered by the GPL-3.0 license above. These files, their copyright holders, and
|
||
their licenses are:
|
||
|
||
| File | Source | Copyright | License |
|
||
| ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------- | -------------------------------------------------------------- |
|
||
| `src/shared/phishingBlocklist.json` | `eth-phishing-detect` community-maintained phishing domain blocklist, vendored from its `src/config.json` | Copyright (c) 2018 kumavis | [DBAD (Don't Be a Dick)](https://github.com/philsturgeon/dbad) |
|
||
| `src/shared/scamlist.js` (address data from MyEtherWallet) | [ethereum-lists](https://github.com/MyEtherWallet/ethereum-lists) `addresses-darklist.json` | Copyright (c) 2020 MyEtherWallet | MIT |
|
||
| `src/shared/scamlist.js` (address data from EtherScamDB) | [EtherScamDB](https://github.com/MrLuit/EtherScamDB) `scams.yaml` | Copyright (c) 2018 Luit Hollander | MIT |
|
||
|
||
The full license texts for these third-party files are included in the
|
||
[LICENSE](LICENSE) file. The `eth-phishing-detect` row carries no repository
|
||
link because the upstream is hosted under a competitor's organization name,
|
||
which project policy keeps out of code and documentation; the vendored copy and
|
||
the runtime refresh both come from that upstream, whose URL is the
|
||
`BLOCKLIST_URL` constant in `src/shared/phishingDomains.js`.
|
||
|
||
## Author
|
||
|
||
[@sneak](https://sneak.berlin)
|