handleRpc(...).then(sendResponse) had no .catch(), and sendResponse is the only
thing that settles the dApp's window.ethereum.request() promise. Any throw
inside handleRpc therefore sent nothing back: the content script posted nothing,
and the page's promise stayed pending forever with no error and no timeout,
indistinguishable from a slow wallet. handleRpc does real work -- state loads,
provider calls, transaction population, approval plumbing -- so "it does not
throw today" was not a property anyone was maintaining.
A rejected handleRpc now answers { code: -32603, message }. -32603 is the
JSON-RPC internal error EIP-1474 defines and EIP-1193 defers to for RPC-layer
failures; no EIP-1193 4xxx code describes "the wallet broke" and none was
invented for it. The cause is not put in the message: the page gets a stable
sentence, the background console gets the method and the throw, so the failure
is visible rather than swallowed.
The two async IIFEs behind AUTISTMASK_TX_RESPONSE and AUTISTMASK_SIGN_RESPONSE
are the same shape one level down. Every statement is inside a try, but a throw
from one of the catch blocks escapes as an unhandled rejection and neither the
popup nor the page is answered. Each gets a last-resort .catch() that settles
the approval through settleApproval() -- the existing chokepoint, with no new
delete or resolve -- and answers the popup. The transaction one reports the
broadcast stage, because it cannot tell whether the transaction reached the
network and that is the wording that does not invite a second send. Every other
message handler on the path is synchronous and cannot leave a promise pending.
Each of the three is driven by a real failure rather than a hook in the handler:
a rejecting extension-storage read, which getState() awaits unguarded, and a
failure classifier that throws while classifying a genuine verification failure.
All three were demonstrated failing against the unfixed code, the RPC one with
sendResponse at zero calls, which is precisely the page-side hang.
AutistMask
AutistMask is a GPL-licensed JavaScript browser extension by @sneak 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
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 thedist/chrome/directory. - Firefox: Navigate to
about:debugging#/runtime/this-firefox, click "Load Temporary Add-on", and selectdist/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:
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 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
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 hookscript/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)script/test-e2e-firefox— run the Firefox browser end-to-end suite (docker required; builds its own pinned image, see End-to-End Tests)script/lint— run the linterscript/fmt— format all files (writes)script/fmt-check— check formatting (read-only)script/check— run test, test-verify-build, lint, and fmt-checkscript/verify-build— assert the compiledDEBUGstate of the bundles indist/: every bundle containingsrc/shared/constants.jsmust haveDEBUGoff, or on whenAUTISTMASK_DEBUG=1. Run automatically at the end ofmake buildandmake build-debug; fails loudly rather than passing if it cannot determine a bundle's state. Not part ofmake check, which does not depend on build artifacts existing.script/test-verify-build— exercise every failure mode ofscript/verify-buildagainst a fixture tree in a temp dir, asserting the exit status and the message of each. Part ofmake check; it reads no build artifacts and writes nothing underdist/. 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 viascript/projectnamescript/cibuild— CI entrypoint: plaindocker build .script/precommit— run by the git pre-commit hook; runsscript/checkscript/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-lockfileon its own, without the rest ofscript/bootstrap. Frozen so a staleyarn.lockfails instead of being silently rewritten. Usemake setupfor a fresh clone.make hooks— shims toscript/install-precommitmake build— build the extension intodist/chrome/anddist/firefox/make build-debug— the same build withAUTISTMASK_DEBUG=1(see Debug Builds)make clean— removedist/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 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), 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 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.
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:
nsIConsoleServicekeeps 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'spageerrorevents place it. - Nothing is stubbed, which inverts the coverage of network-dependent code.
There is no fixture layer; the container runs with
--network noneinstead, 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. AReferenceErrorin the success path ofrenderTransactions, 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:
setIntervalandsetTimeoutare 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.
localStoragedoes 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 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 storedxpubbysrc/shared/walletDefects.js, explained in the wallet list, and blocked from signing, sending and private-key export. It is never deleted or rewritten.
- An HD wallet (
- 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.
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.hasWalletis 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)
- Tap address row → sets the active address and broadcasts
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 thegasLimit * maxFeePerGasreserve 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
getTransactionReceiptevery 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
selectedTokenset) 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
selectedTokenset) 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_chainIdbefore being saved) - Blockscout API: endpoint URL input + "Save" button (validated against
/statsbefore 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_CHANGEDis 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 (
nextIndexis a high-water mark), and re-importing the wallet's key material is rejected as a duplicate byfindWalletByXpubwhile 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, whereuponscanForAddresses()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 byrecoveryPathText()rather than sitting inindex.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 viagetAddressValueUsd(). The sentence names no figure of its own: the lines round to four decimals, so a sentence built from a rounded number would report0.0000 ETHfor an address holding real money. The predicate isaddressHoldsFunds()insrc/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_CHANGEDbroadcast 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_requestAccountsorwallet_requestPermissionsand 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, oreth_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 tohttps://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) — 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 thecrypto_secretboxoutput). - 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.
- The user's password is run through Argon2id (
- 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.ethereumobject 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:
-
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.
-
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;
TOKENSinsrc/shared/tokenList.jsis 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 contract0xD05339f9Ea5ab9d9F03B9d57F671d2abD1F55c82, 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 fromsrc/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
0x2708ebddfb9b5fa3f7a89d3ea398ef9fd8771b83ed861ecb7c21cd55d18edc74sent 1 gwei (0.000000001 ETH) from0xC3c6B3b4402bD78A9582aB6b00E747769344F37E— another look-alike of the legitimate recipient0xC3c693.... 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 of0hides 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 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()andtransfer()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
- Delete wallet (with confirmation)
- Delete address from HD wallet (with confirmation)
- Show wallet's recovery phrase (requires password)
Transactions
- Gas estimation and fee display before confirming
Testing
- Tests for mnemonic generation and address derivation
- 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.
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) |
src/shared/scamlist.js (address data from MyEtherWallet) |
ethereum-lists addresses-darklist.json |
Copyright (c) 2020 MyEtherWallet | MIT |
src/shared/scamlist.js (address data from EtherScamDB) |
EtherScamDB scams.yaml |
Copyright (c) 2018 Luit Hollander | MIT |
The full license texts for these third-party files are included in the
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.