Compare commits

..

5 Commits

Author SHA1 Message Date
38596b4c79 build: assert DEBUG is off in every emitted bundle (closes #170)
All checks were successful
check / check (push) Successful in 47s
PR #169 made DEBUG a build-time flag defaulting off, but nothing guarded
the wiring. The tests load src/shared/constants.js outside a bundle and
take the jest fallback branch, so deleting the __BUILD_DEBUG__ define
from build.js left all tests passing and make check green while silently
restoring the drainable-wallet vulnerability in every shipped artifact.
The property only exists in the emitted output, so it is now asserted
against the emitted output.

script/verify-build reads two independent facts per bundle. Which
bundles must be inspected comes from esbuild's metafile: build.js writes
dist/constants-bundles.txt naming every emitted JS output whose input
set includes constants.js, so the set is derived from the real
dependency graph rather than a hardcoded count or filenames. What each
bundle's DEBUG state is comes from BUILD_DEBUG_MARKER, a new constant
derived from DEBUG itself that the bundler folds to exactly one of two
string literals. Deriving the bundle set from the marker would be the
silent-pass hole: a bundle with no marker would be indistinguishable
from content/index.js, which legitimately contains none.

The marker is a plain string rather than a match on minified `DEBUG:!1`,
because minifier output is not a contract across esbuild versions. When
DEBUG is not known at build time the fold cannot happen and both
literals survive, which is exactly the shape of the regression this
guards against. Every way of failing to determine a bundle's state is a
hard failure: missing manifest, empty manifest, a listed file that does
not exist, both markers, neither marker, the wrong marker, or a bundle
carrying a marker while absent from the manifest. There is no path on
which the script exits 0 without positively identifying the expected
marker in at least one bundle.

It runs on the build path only. make build and make build-debug both
invoke it, the latter asserting the inverse, and Dockerfile:17 runs a
bare make build, so CI fails on a release build with a live debug
branch. It is deliberately not in script/check: that would make check
depend on dist/ existing and pull a full build into its time budget, and
the obvious workaround -- skip when dist/ is absent -- is precisely the
silently-green behaviour this exists to prevent.
2026-08-10 13:56:13 +00:00
188882d635 test: cover the address-poisoning filters in transactions.js (closes #160)
Some checks failed
check / check (push) Has been cancelled
47 tests over src/shared/transactions.js: both real address-poisoning attacks
as fixtures, each of the four filters on and off, threshold boundaries, no
false positives, and the per-address merge/dedup path. No source file changed.
2026-08-10 15:53:15 +02:00
e8ad8325c8 test: containerized Chrome end-to-end harness that drives the real popup (closes #181)
Some checks failed
check / check (push) Has been cancelled
Runs the real popup in a pinned containerized Chrome and fails on any uncaught
page error or console.error. Also fixes the two defects it caught: the missing
showView import in addToken.js and the missing addressDotHtml import in
transactionDetail.js.

closes #150
closes #151
2026-08-10 15:49:32 +02:00
f7f141a757 security: make DEBUG a build-time flag defaulting to off (closes #149) (#169)
Some checks failed
check / check (push) Has been cancelled
Fixes the highest-severity item in the repo: `src/shared/constants.js` had
`const DEBUG = true;`, so `generateMnemonic()` returned the publicly committed
`DEBUG_MNEMONIC` for every wallet created from a build of `main`, and the real
entropy path was dead code in every artifact we could produce.

## What changed

**`build.js`** — `AUTISTMASK_DEBUG` is read from the environment and injected
as a `__BUILD_DEBUG__` entry in the existing esbuild `define` map, next to the
other `__BUILD_*__` defines. Only the exact value `1` enables it; unset, empty,
`true`, or a typo all yield a release build, so the insecure direction requires
a deliberate opt-in and any mistake fails safe. The build prints
`Build mode: release (DEBUG off)` or
`Build mode: DEBUG (INSECURE - hardcoded test mnemonic, do not ship)`.

**`src/shared/constants.js`** — `DEBUG` now uses the same `typeof` guard that
`src/shared/buildInfo.js` already uses for the other build-time defines, and
defaults to `false` when the define is absent (jest, plain `require`).
`DEBUG_MNEMONIC` stays in the tree and stays exported.

**`Makefile`** — new `build-debug` target (`AUTISTMASK_DEBUG=1` + the same
build) so a debug build stays a one-liner for development.

**`README.md`** — new "Debug Builds" subsection under Getting Started, and the
DEBUG Mode Policy section now states that `DEBUG` is build-time-only and spells
out the boundary against the runtime toggle.

**`tests/wallet.test.js`** — new, covering both build modes.

**`TODO.md`** — refreshed in the same commit (details at the bottom).

No new `if (DEBUG)` branch was added and nothing about what DEBUG *does* changed:
still exactly the red banner plus the hardcoded test phrase, per the README
DEBUG Mode Policy and `RULES.md:76-80`.

## The interaction with the #145 settings toggle

This is the subtle part, so spelling out the reasoning.

There are two distinct debug flags in the tree after #145:

1. the compile-time `DEBUG` constant from `constants.js`, and
2. the runtime `debugMode` state flag, which the settings easter egg toggles
   and which `settings.js:379` pushes into `log.js` via `setRuntimeDebug()`.

`log.js` merges them: `isDebug()` is `DEBUG || _runtimeDebug`. That merged
value feeds exactly two things — the log level threshold (`log.js:24`) and the
red banner (`views/helpers.js:71`). Making the banner user-toggleable is the
intended behavior of #145, and this PR leaves it alone.

`generateMnemonic()` does **not** consult `isDebug()`. It reads the
compile-time `DEBUG` binding directly. That distinction is what makes a release
build coherent: with `__BUILD_DEBUG__` false, `DEBUG` is false in the bundle,
so no amount of clicking the version ten times and flipping the toggle can
reach `return DEBUG_MNEMONIC`. The user can turn the banner and verbose logging
on in a release build; they cannot turn the hardcoded phrase on.

The failure mode to guard against is someone later "tidying up" the two flags by
routing `wallet.js` through `isDebug()`, which would silently reintroduce this
exact vulnerability with the runtime toggle as the trigger. Three things now
guard that: a comment at the `wallet.js` call site saying it must stay the
compile-time constant and why, the same statement in the README DEBUG Mode
Policy, and a regression test that calls `setRuntimeDebug(true)`, asserts
`isDebug()` is genuinely true, and then asserts `generateMnemonic()` still
returns fresh entropy.

I considered instead making the runtime toggle unavailable in release builds,
but rejected it: that removes a feature #145 deliberately added, and it defends
the wrong boundary. The banner is not the dangerous part; the mnemonic path is,
and that one is already unreachable.

## Verification

`make check` — green, 5 suites, 55 tests, plus lint and fmt-check. It also ran
via the pre-commit hook on the commit itself.

The new tests, per the verification standard in the manager comment on the
issue (not just `a !== b`) — with the flag off: two successive
`generateMnemonic()` calls differ, both pass `isValidMnemonic`, both are 12
words, neither equals `DEBUG_MNEMONIC`, and the result derives a usable HD
wallet (`xpub` + a well-formed first address), so a broken implementation
returning a counter or a truncated phrase would fail. Same assertions again
with the runtime toggle forced on. With the flag on (`__BUILD_DEBUG__` defined
before a `jest.resetModules()` re-require): `DEBUG` is `true` and
`generateMnemonic()` returns `DEBUG_MNEMONIC`, so the debug path is proven
working rather than silently deleted.

Build artifacts — `make build` and `make build-debug` both produce `dist/chrome`
and `dist/firefox` successfully. Grepping the minified bundles for the emitted
`DEBUG` export value across all four bundles (chrome popup, chrome background,
firefox popup, firefox background):

    # after make build
    $ grep -roh 'DEBUG:![01]' dist/chrome dist/firefox | sort | uniq -c
          4 DEBUG:!1

    # after make build-debug
    $ grep -roh 'DEBUG:![01]' dist/chrome dist/firefox | sort | uniq -c
          4 DEBUG:!0

`!1` is minified `false`, `!0` is `true`. Also checked the fail-safe path:
`AUTISTMASK_DEBUG=true make build` prints `Build mode: release (DEBUG off)` and
likewise yields `4 DEBUG:!1`.

One thing a reviewer should know about the grep: the `DEBUG_MNEMONIC` string
literal is still present in the release bundle. That is not a leak of anything
(the phrase is in this public repo already) and it does not mean the branch is
live — esbuild cannot tree-shake a CommonJS `module.exports` object, so the
constant survives while `DEBUG` folds to `false`. The compiled function is
`function PL(){return ML?UL:f_.fromEntropy(globalThis.crypto.getRandomValues(new Uint8Array(16))).phrase}`
where `ML` is the `DEBUG:!1` export. So "the phrase string is absent" is *not*
the right test for a release build; "the exported `DEBUG` is `!1`" is, which is
what I checked.

## `TODO.md` refresh

Per the manager comment: Status rewritten (no branch in flight —
`feat/issue-144-settings-about` landed as #145, scripts-to-rule-them-all landed
as #148, so the `scripts/` question is resolved; `make check` recorded as
verified green on `main` at `23aeae4`); the completed "Verify main passes make
check" Future Step removed; Future Steps rewritten against the #149-#168
backlog in rough priority order, keeping branch pruning (now #167) and the
pre-1.0 security review (noting #149 and #157 are parts of it but it is
broader).

One deliberate deviation to flag rather than bury: the manager asked that Next
Step become this issue. Taken literally against the Workflow section, this
commit *completes* #149, which would normally move it into Completed Steps. I
followed the repo's existing convention for in-flight work instead — the
previous Next Step was phrased as "Land feat/issue-144-settings-about", so Next
Step is now "Land #149 ... PR open, awaiting review", which is accurate until
this merges. Whoever merges should move it to Completed Steps and promote the
first Future Step. Happy to change it if the reviewer prefers the strict
reading.

## Out of scope

`script/lint` being `prettier --check` only and unable to catch undefined
identifiers (#152) — noted in the TODO but not fixed here; I greped for `DEBUG`
consumers by hand rather than relying on lint, as advised. Nothing else in the
DEBUG consumer set (`log.js`, `helpers.js`, `state.js`, `settings.js`) changed
behavior.

Co-authored-by: sneak <sneak@sneak.berlin>
Reviewed-on: #169
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-08-09 16:19:09 +02:00
23aeae4841 feat: add About well to settings with build info and debug easter egg (#145)
All checks were successful
check / check (push) Successful in 22s
## Summary

Add a new well at the bottom of the settings view displaying application info (license, author, version, build date, git commit hash linked to Gitea), and an easter egg that reveals a debug mode toggle after clicking the version number 10 times.

## Changes

- **`src/popup/index.html`**: Added About well with license, author, version, build date, and linked commit hash. Added hidden debug well with debug mode toggle.
- **`src/popup/views/settings.js`**: Populate About well from build-time constants. Implement version click counter (10 clicks reveals debug well). Wire up debug mode toggle to state and runtime logger.
- **`src/shared/buildInfo.js`** (new): Module exporting build-time constants (`BUILD_VERSION`, `BUILD_LICENSE`, `BUILD_AUTHOR`, `BUILD_COMMIT`, `BUILD_DATE`, `GITEA_COMMIT_URL`) injected by esbuild define.
- **`build.js`**: Read git commit hash (short + full) and version from package.json, pass as esbuild `define` constants. Also reads `GIT_COMMIT_SHORT`/`GIT_COMMIT_FULL` env vars for Docker builds.
- **`Dockerfile`**: Accept `GIT_COMMIT_SHORT` and `GIT_COMMIT_FULL` build args, set as env vars for the build step.
- **`Makefile`**: Pass git commit hashes as Docker build args.
- **`src/shared/state.js`**: Add `debugMode` boolean to state (persisted).
- **`src/shared/log.js`**: Add runtime debug flag that supplements the compile-time `DEBUG` constant. Debug mode toggle updates this flag immediately.

closes #144

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Co-authored-by: clawbot <clawbot@sneak.cloud>
Co-authored-by: user <user@Mac.lan guest wan>
Co-authored-by: clawbot <clawbot@eeqj.de>
Co-authored-by: sneak <sneak@sneak.berlin>
Reviewed-on: #145
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-07-26 18:39:09 +02:00
30 changed files with 2824 additions and 2400 deletions

View File

@@ -1,4 +1,3 @@
.git
node_modules node_modules
.DS_Store .DS_Store
dist dist

View File

@@ -1,4 +1,4 @@
.PHONY: bootstrap setup install test lint fmt fmt-check check docker hooks build clean dev .PHONY: bootstrap setup install test test-e2e lint fmt fmt-check check docker hooks build build-debug verify-build clean dev
# Standard targets are thin shims; the implementations live in script/ # Standard targets are thin shims; the implementations live in script/
# per the scripts-to-rule-them-all pattern (see the Entrypoints section # per the scripts-to-rule-them-all pattern (see the Entrypoints section
@@ -16,6 +16,10 @@ install:
test: test:
@script/test @script/test
# Browser end-to-end suite. Requires docker; not part of check.
test-e2e:
@script/test-e2e
lint: lint:
@script/lint @script/lint
@@ -37,6 +41,20 @@ hooks:
build: build:
@echo "Building extension..." @echo "Building extension..."
@yarn run build 2>&1 @yarn run build 2>&1
@script/verify-build
# Development-only build: enables the red DEBUG / INSECURE banner and makes
# the hardcoded test recovery phrase the output of wallet creation. Never
# distribute the artifacts this produces.
build-debug:
@echo "Building extension (DEBUG)..."
@AUTISTMASK_DEBUG=1 yarn run build 2>&1
@AUTISTMASK_DEBUG=1 script/verify-build
# Assert the compiled DEBUG state of the bundles already in dist/. Runs at
# the end of build and build-debug; separate target for re-running it alone.
verify-build:
@script/verify-build
clean: clean:
@rm -rf dist/ @rm -rf dist/

105
README.md
View File

@@ -42,6 +42,30 @@ Load the extension:
- **Firefox**: Navigate to `about:debugging#/runtime/this-firefox`, click "Load - **Firefox**: Navigate to `about:debugging#/runtime/this-firefox`, click "Load
Temporary Add-on", and select `dist/firefox/manifest.json`. 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 ## Entrypoints
This repository adheres to the This repository adheres to the
@@ -56,15 +80,83 @@ provide:
git pre-commit hook git pre-commit hook
- `script/projectname` — print the project name (used for the Docker image tag) - `script/projectname` — print the project name (used for the Docker image tag)
- `script/test` — run the test suite (jest) - `script/test` — run the test suite (jest)
- `script/test-e2e` — run the browser end-to-end suite (docker required; see
[End-to-End Tests](#end-to-end-tests))
- `script/lint` — run the linter - `script/lint` — run the linter
- `script/fmt` — format all files (writes) - `script/fmt` — format all files (writes)
- `script/fmt-check` — check formatting (read-only) - `script/fmt-check` — check formatting (read-only)
- `script/check` — run test, lint, and fmt-check - `script/check` — run test, 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/docker` — build the Docker image tagged via `script/projectname` - `script/docker` — build the Docker image tagged via `script/projectname`
- `script/cibuild` — CI entrypoint: plain `docker build .` - `script/cibuild` — CI entrypoint: plain `docker build .`
- `script/precommit` — run by the git pre-commit hook; runs `script/check` - `script/precommit` — run by the git pre-commit hook; runs `script/check`
- `script/install-precommit` — install the git pre-commit hook - `script/install-precommit` — install the git pre-commit hook
## End-to-End Tests
`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, wallet creation through the UI, the Add Token screen and
the transaction detail screen for an ERC-20 transfer. 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.
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 unconditionally — 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.
`make test-e2e` is deliberately **not** 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. It is also not wired into the Gitea workflow yet — docker-in-docker in
CI is a separate question. Run it locally before changing anything under
`src/popup/views/`.
## Rationale ## Rationale
Common popular EVM wallets have become bloated with swap UIs, portfolio Common popular EVM wallets have become bloated with swap UIs, portfolio
@@ -668,6 +760,19 @@ flows, or alter program behavior beyond the banner and the hardcoded mnemonic.
Adding new DEBUG-conditional branches requires explicit approval from the Adding new DEBUG-conditional branches requires explicit approval from the
project owner. 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 ### Key Decisions
- **No framework**: The popup UI is vanilla JS and HTML. The extension is small - **No framework**: The popup UI is vanilla JS and HTML. The extension is small

85
TODO.md
View File

@@ -10,24 +10,44 @@
# Status # Status
pre-1.0. Tagged v0.1.0 on 2026-02-27. Active development on branch pre-1.0, working towards the 1.0.0 milestone. Tagged v0.1.0 on 2026-02-27. No
feat/issue-144-settings-about (another agent working as of 2026-07-06). Full other branch is in flight: the settings About well landed as #145 on 2026-07-26
policy file set present; make check on main not verified. and scripts-to-rule-them-all landed as #148, so the `scripts/` directory
question is resolved. Full policy file set present. `make check` verified
passing on `main` at `23aeae4` on 2026-08-09. The 1.0.0 backlog is filed as
#149-#168. A real-browser end-to-end suite (`make test-e2e`) now sits alongside
`make check`, which cannot see a runtime `ReferenceError` in a popup view.
# Next Step # Next Step
Land feat/issue-144-settings-about: finish the settings About well (build info, Land #149: make `DEBUG` a build-time constant that defaults to off, injected as
app name and repo link, release date, version click easter egg, git info derived the `__BUILD_DEBUG__` esbuild define from `AUTISTMASK_DEBUG=1`, so a plain
inside Docker), resolve the untracked scripts/ directory (commit or gitignore), `make build` stops handing every newly created wallet the publicly committed
get review, merge to main. test recovery phrase. Branch `fix/issue-149-debug-build-flag`; PR open, awaiting
review.
# Completed Steps # Completed Steps
- 2026-08-09: Post-build assertion that every emitted bundle containing
`constants.js` has `DEBUG` compiled off, via `script/verify-build` on the
`make build` path (#170).
- 2026-08-09: Containerized Chrome end-to-end harness (`make test-e2e` /
`script/test-e2e`) driving the real popup with all network intercepted, plus
the two used-but-not-imported crashes it caught: AddToken unreachable (#150)
and TransactionDetail broken for every ERC-20 transfer (#151). Harness
demonstrated failing before the fixes and passing after (#181). Interception
covers the MV3 background service worker, not just the popup page, and a
launch-time canary aborts the suite if worker traffic starts escaping.
- 2026-08-09: Reviewed the repo end to end and filed the 1.0.0 backlog
(#149-#168).
- 2026-08-09: Test coverage for the address-poisoning defense in
`src/shared/transactions.js` (#160)
- 2026-07-26: About well in settings with build info, repo link and the version
click easter egg (#145); proper view navigation stack (#146).
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile - 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
shims, README Entrypoints section shims, README Entrypoints section (#148)
- 2026-03-01: About well in settings with build info and easter egg (in flight - 2026-03-01: USD display suppressed on testnets (#142); estimated USD for ETH
on feature branch); USD display suppressed on testnets (#142); estimated USD in approve-tx view (#141).
for ETH in approve-tx view (#141).
- Sepolia testnet support (#137); etherscan links go to token-specific URLs - Sepolia testnet support (#137); etherscan links go to token-specific URLs
(#136). (#136).
- Transaction detail improvements: Type field and on-chain details (#130), - Transaction detail improvements: Type field and on-chain details (#130),
@@ -45,12 +65,39 @@ get review, merge to main.
# Future Steps # Future Steps
- Verify main passes make check after the feature branch merges (not verified - Add ESLint to `script/lint` (#152). `make check` is `prettier --check` only
2026-07-06 because an agent was active in the tree); fix anything red. main and cannot catch undefined identifiers, which is how #150 and #151 shipped.
must always be green. - Decide the libsodium backend that actually ships (#182) and delete the single
- Prune stale branches: dozens of merged local and remote feature branches allowlist entry it owns in `tests/e2e/harness.js`.
remain (fix/_, feature/_, tx-\*); delete merged ones locally and on origin. - Extend the end-to-end suite to the dApp approval signing path (EIP-1193
- Continue the issue backlog toward a feature-complete wallet, then cut further through the real content script, background worker and approval popup). That
tags as milestones land. path needs a CDP-based route to background-worker console output first:
Playwright exposes no error event for service workers, so an uncaught
exception in the worker cannot fail the run today (worker network traffic is
already covered). Decide separately whether docker-in-docker makes
`make test-e2e` runnable in the Gitea workflow.
- Make the Firefox target functional: Chrome callback APIs are used against the
promise-only `browser` namespace (#153).
- Send and transaction-flow correctness: gas fee excluded from the
insufficient-balance check (#154), WaitTx 60s timeout overwriting a rendered
success screen (#155), last-wallet deletion leaving inconsistent state (#156).
- Security: plaintext password crossing the extension messaging boundary during
dApp approvals (#157); MV3 service worker termination killing the background
refresh and the 24h phishing list update (#158).
- Test the crypto core — `wallet.js` derivation and `vault.js` encryption (#159)
— and the address-poisoning defense in `transactions.js` (#160).
- Wallet features for 1.0: show a wallet's recovery phrase behind the password
(#161), delete an address from an HD wallet (#162).
- Docs: `docs/README.md` contradicts the code on external services and names
competitors (#163); README Screen Map omits three shipped screens (#164).
- Owner decisions: Sepolia support versus "Non-Goals for 1.0", and `isMetaMask`
naming a competitor in shipped code (#165).
- Repo policy compliance sweep: test rerun pattern, `yarn`/`npx`, frozen
lockfile, undocumented Makefile targets (#166).
- Prune the 24 stale remote feature branches (#167).
- Remove dead exports and de-duplicate copy-pasted view helpers (#168).
- Pre-1.0 security review of the extension (key handling, DEBUG mode policy, RPC - Pre-1.0 security review of the extension (key handling, DEBUG mode policy, RPC
input validation) before any 1.0rc tag. input validation) before any 1.0rc tag; #149 and #157 are parts of it, but the
review is broader than either.
- Cut 1.0.0 once the milestone is empty, then continue tagging as milestones
land.

183
build.js
View File

@@ -3,75 +3,168 @@ const path = require("path");
const { execSync } = require("child_process"); const { execSync } = require("child_process");
const esbuild = require("esbuild"); const esbuild = require("esbuild");
const DIST_CHROME = path.join(__dirname, "dist", "chrome"); const DIST = path.join(__dirname, "dist");
const DIST_FIREFOX = path.join(__dirname, "dist", "firefox"); const DIST_CHROME = path.join(DIST, "chrome");
const DIST_FIREFOX = path.join(DIST, "firefox");
const SRC = path.join(__dirname, "src"); const SRC = path.join(__dirname, "src");
// The module whose compiled DEBUG state script/verify-build asserts, and the
// manifest naming every emitted bundle that ends up containing it. The
// manifest is derived from esbuild's own dependency graph rather than from a
// hardcoded list, so it tracks the bundle layout instead of rotting with it.
const AUDITED_MODULE = "src/shared/constants.js";
const BUNDLE_MANIFEST = path.join(DIST, "constants-bundles.txt");
function ensureDir(dir) { function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
} }
// Repo-relative, forward-slashed, so the manifest reads the same on every
// platform and can be consumed by a POSIX shell script without further work.
function repoRelative(p) {
return path.relative(__dirname, p).split(path.sep).join("/");
}
// Collect the outputs of one esbuild run that bundle AUDITED_MODULE. esbuild
// reports every input that contributed to an output in the metafile, which is
// the authoritative answer to "is constants.js in this bundle" — unlike
// searching the minified text, it does not depend on what survived minification.
function outputsContainingAuditedModule(metafile) {
return Object.entries(metafile.outputs)
.filter(([outFile, info]) => {
if (!outFile.endsWith(".js")) return false;
return Object.keys(info.inputs).some(
(input) => repoRelative(input) === AUDITED_MODULE,
);
})
.map(([outFile]) => repoRelative(outFile));
}
// DEBUG is a build-time flag, off unless explicitly requested. It is the only
// thing that makes the hardcoded test mnemonic reachable, so the opt-in must be
// exact: anything other than the literal "1" (unset, empty, "true", a typo)
// produces a release build. Failing towards the safe mode is deliberate.
function isDebugBuild() {
return process.env.AUTISTMASK_DEBUG === "1";
}
function getBuildInfo() {
const pkg = JSON.parse(
fs.readFileSync(path.join(__dirname, "package.json"), "utf8"),
);
let commitHash = "unknown";
try {
commitHash = execSync("git rev-parse --short HEAD", {
encoding: "utf8",
}).trim();
} catch (_) {
// not a git repo or git not available
}
let commitHashFull = "unknown";
try {
commitHashFull = execSync("git rev-parse HEAD", {
encoding: "utf8",
}).trim();
} catch (_) {
// not a git repo or git not available
}
return {
version: pkg.version,
license: pkg.license,
author: pkg.author,
commitHash,
commitHashFull,
buildDate: new Date().toISOString().slice(0, 10),
};
}
async function build() { async function build() {
console.log("Building AutistMask extension..."); console.log("Building AutistMask extension...");
const buildInfo = getBuildInfo();
console.log("Build info:", buildInfo);
const debugBuild = isDebugBuild();
console.log(
debugBuild
? "Build mode: DEBUG (INSECURE - hardcoded test mnemonic, do not ship)"
: "Build mode: release (DEBUG off)",
);
const define = {
__BUILD_DEBUG__: JSON.stringify(debugBuild),
__BUILD_VERSION__: JSON.stringify(buildInfo.version),
__BUILD_LICENSE__: JSON.stringify(buildInfo.license),
__BUILD_AUTHOR__: JSON.stringify(buildInfo.author),
__BUILD_COMMIT__: JSON.stringify(buildInfo.commitHash),
__BUILD_COMMIT_FULL__: JSON.stringify(buildInfo.commitHashFull),
__BUILD_DATE__: JSON.stringify(buildInfo.buildDate),
};
// Emitted bundles that contain constants.js, accumulated across every
// esbuild run below and written out for script/verify-build.
const auditedBundles = [];
// compile tailwind CSS // compile tailwind CSS
console.log("Compiling Tailwind CSS..."); console.log("Compiling Tailwind CSS...");
const tailwindInput = path.join(SRC, "popup", "styles", "main.css"); const tailwindInput = path.join(SRC, "popup", "styles", "main.css");
const tailwindOutput = path.join(__dirname, "dist", "styles.css"); const tailwindOutput = path.join(DIST, "styles.css");
ensureDir(path.join(__dirname, "dist")); ensureDir(DIST);
// Drop any manifest from a previous build before emitting anything, so a
// build that never gets around to writing one cannot be verified against
// a stale list.
fs.rmSync(BUNDLE_MANIFEST, { force: true });
execSync( execSync(
`npx @tailwindcss/cli -i ${tailwindInput} -o ${tailwindOutput} --minify`, `npx @tailwindcss/cli -i ${tailwindInput} -o ${tailwindOutput} --minify`,
{ stdio: "inherit" }, { stdio: "inherit" },
); );
// Every bundle goes through here, so metafile collection cannot be
// forgotten when a new entry point is added.
async function bundle(entryPoint, outfile) {
const result = await esbuild.build({
entryPoints: [entryPoint],
bundle: true,
format: "iife",
outfile,
platform: "browser",
target: ["chrome110", "firefox110"],
minify: true,
metafile: true,
define,
});
auditedBundles.push(...outputsContainingAuditedModule(result.metafile));
}
for (const distDir of [DIST_CHROME, DIST_FIREFOX]) { for (const distDir of [DIST_CHROME, DIST_FIREFOX]) {
ensureDir(path.join(distDir, "src", "popup")); ensureDir(path.join(distDir, "src", "popup"));
ensureDir(path.join(distDir, "src", "background")); ensureDir(path.join(distDir, "src", "background"));
ensureDir(path.join(distDir, "src", "content")); ensureDir(path.join(distDir, "src", "content"));
// bundle popup JS with esbuild (inlines ethers, libsodium, etc.) // bundle popup JS with esbuild (inlines ethers, libsodium, etc.)
await esbuild.build({ await bundle(
entryPoints: [path.join(SRC, "popup", "index.js")], path.join(SRC, "popup", "index.js"),
bundle: true, path.join(distDir, "src", "popup", "index.js"),
format: "iife", );
outfile: path.join(distDir, "src", "popup", "index.js"),
platform: "browser",
target: ["chrome110", "firefox110"],
minify: true,
});
// bundle background script // bundle background script
await esbuild.build({ await bundle(
entryPoints: [path.join(SRC, "background", "index.js")], path.join(SRC, "background", "index.js"),
bundle: true, path.join(distDir, "src", "background", "index.js"),
format: "iife", );
outfile: path.join(distDir, "src", "background", "index.js"),
platform: "browser",
target: ["chrome110", "firefox110"],
minify: true,
});
// bundle content script // bundle content script
await esbuild.build({ await bundle(
entryPoints: [path.join(SRC, "content", "index.js")], path.join(SRC, "content", "index.js"),
bundle: true, path.join(distDir, "src", "content", "index.js"),
format: "iife", );
outfile: path.join(distDir, "src", "content", "index.js"),
platform: "browser",
target: ["chrome110", "firefox110"],
minify: true,
});
// bundle inpage script (injected into page context, separate file) // bundle inpage script (injected into page context, separate file)
await esbuild.build({ await bundle(
entryPoints: [path.join(SRC, "content", "inpage.js")], path.join(SRC, "content", "inpage.js"),
bundle: true, path.join(distDir, "src", "content", "inpage.js"),
format: "iife", );
outfile: path.join(distDir, "src", "content", "inpage.js"),
platform: "browser",
target: ["chrome110", "firefox110"],
minify: true,
});
// copy popup HTML // copy popup HTML
fs.copyFileSync( fs.copyFileSync(
@@ -96,6 +189,16 @@ async function build() {
path.join(DIST_FIREFOX, "manifest.json"), path.join(DIST_FIREFOX, "manifest.json"),
); );
// Written last so a build that died partway through leaves no manifest
// at all, which script/verify-build treats as a hard failure rather than
// as "nothing to check".
const manifest = [...new Set(auditedBundles)].sort();
fs.writeFileSync(BUNDLE_MANIFEST, manifest.map((p) => `${p}\n`).join(""));
console.log(
`Bundles containing ${AUDITED_MODULE}: ${manifest.length} ` +
`(listed in ${repoRelative(BUNDLE_MANIFEST)})`,
);
console.log("Build complete: dist/chrome/ and dist/firefox/"); console.log("Build complete: dist/chrome/ and dist/firefox/");
} }

View File

@@ -16,6 +16,7 @@
"@tailwindcss/cli": "^4.2.1", "@tailwindcss/cli": "^4.2.1",
"esbuild": "^0.27.3", "esbuild": "^0.27.3",
"jest": "^30.2.0", "jest": "^30.2.0",
"playwright-core": "1.56.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"tailwindcss": "^4.2.1" "tailwindcss": "^4.2.1"
}, },

64
script/test-e2e Executable file
View File

@@ -0,0 +1,64 @@
#!/bin/sh
# script/test-e2e: build the extension and drive the real popup in a real
# Chromium inside a pinned container. Our own extension to
# scripts-to-rule-them-all.
#
# Deliberately NOT called by script/check or script/test: REPO_POLICIES.md
# caps make test at 20 seconds and a browser suite does not fit. Run it
# yourself before touching popup views; it is the only check that can see
# a used-but-not-imported identifier blow up at runtime.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# mcr.microsoft.com/playwright:v1.56.0-noble, 2026-08-09
#
# The playwright-core devDependency is pinned to the matching Playwright
# version (1.56.0) and the two must be bumped together: the browsers ship
# inside this image, and playwright-core looks for the exact browser
# revision its own version expects. A mismatch fails at launch.
IMAGE="mcr.microsoft.com/playwright@sha256:35246d87a7c88ea9b771c65d33171b2611b02a8253b4b12ce6f94376c55f99f2"
main() {
cd "$ROOT"
if ! command -v docker >/dev/null 2>&1; then
echo "test-e2e: docker is required to run the e2e suite" >&2
exit 1
fi
echo "Building extension for e2e..."
yarn run build 2>&1
echo "Running e2e suite in the pinned Playwright container..."
# --ipc=host: Chromium's shared-memory needs more than the default
# 64MB /dev/shm or renderers crash.
# --user: keep files the suite touches owned by the caller, not root.
# HOME=/tmp: the mapped uid has no home directory in the image.
# PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1: without it,
# ctx.route() intercepts page requests only, and every fetch made by
# the MV3 background service worker — including the phishing
# blocklist fetch that src/background/index.js issues at worker
# startup — goes to the real internet. The flag is experimental and
# Playwright may drop or rename it. It cannot break silently: the
# harness probes service-worker interception at launch and aborts
# the whole suite if it is not in effect (see the interception
# canary in tests/e2e/harness.js). If a future Playwright removes
# the flag, that probe is what will fail, and the fix is either a
# replacement mechanism or an honest downgrade of the isolation
# claim in tests/e2e/network.js and README.md — not deleting the
# probe. The image is pinned by digest, so this can only ever bite
# on a deliberate bump.
docker run --rm \
--ipc=host \
--user "$(id -u):$(id -g)" \
-e HOME=/tmp \
-e PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1 \
-e "E2E_TRACE_NETWORK=${E2E_TRACE_NETWORK:-0}" \
-v "$ROOT:/work" \
-w /work \
"$IMAGE" \
node tests/e2e/run.js
}
main "$@"

136
script/verify-build Executable file
View File

@@ -0,0 +1,136 @@
#!/bin/sh
# script/verify-build: assert the compiled DEBUG state of the emitted
# bundles. Our own extension to scripts-to-rule-them-all, run at the end of
# make build / make build-debug.
#
# Why this exists: DEBUG makes the publicly committed test recovery phrase the
# output of wallet creation, so a release artifact built with it live hands
# every new wallet to anyone who reads the repo. The test suite cannot see
# this, because it loads src/shared/constants.js outside a bundle and takes
# the fallback branch; the property only exists in the emitted output, so it
# has to be asserted against the emitted output.
#
# What it reads: dist/constants-bundles.txt, written by build.js from
# esbuild's metafile, naming every emitted bundle that contains
# src/shared/constants.js. Each of those must carry exactly one of the two
# BUILD_DEBUG_MARKER literals that constants.js folds down to.
#
# It fails rather than passes whenever it cannot determine a bundle's state.
# Minified output is not a stable contract, so "matched neither form" is not
# evidence of anything and must never read as green.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
MANIFEST="dist/constants-bundles.txt"
MARKER_ON="autistmask-build-debug=on"
MARKER_OFF="autistmask-build-debug=off"
# Set by read_marker.
MARKER=""
fail() {
echo "verify-build: FAIL: $*" >&2
exit 1
}
has_marker() {
grep -q -F "$1" "$2" 2>/dev/null
}
# Read one bundle's DEBUG state into MARKER. Exactly one marker must be
# present. Both means the ternary in constants.js was never folded, which is
# what happens when the __BUILD_DEBUG__ define goes missing from build.js:
# DEBUG stops being known at build time and the debug branch is live again.
# Neither means we are reading output we do not understand. Both are hard
# failures; neither is ever treated as absence of a problem.
read_marker() {
_file="$1"
_on=no
_off=no
if has_marker "$MARKER_ON" "$_file"; then _on=yes; fi
if has_marker "$MARKER_OFF" "$_file"; then _off=yes; fi
if [ "$_on" = yes ] && [ "$_off" = yes ]; then
fail "$_file carries both debug markers, so the build-time DEBUG value
was never resolved and the debug branch is still live. Check that build.js
still defines __BUILD_DEBUG__."
fi
if [ "$_on" = no ] && [ "$_off" = no ]; then
fail "$_file carries no debug marker, so its DEBUG state cannot be
determined. Either BUILD_DEBUG_MARKER is gone from src/shared/constants.js
or the emitted output changed shape. Refusing to report success."
fi
if [ "$_on" = yes ]; then
MARKER="$MARKER_ON"
else
MARKER="$MARKER_OFF"
fi
}
# The manifest says which bundles must carry a marker. This says no other
# emitted bundle may carry one, which catches a manifest that has gone stale
# or short rather than trusting whatever it happens to list.
check_unlisted_bundles() {
_listing="$(find dist -type f -name '*.js' | sort)"
while read -r _file; do
[ -n "$_file" ] || continue
if grep -q -x -F "$_file" "$MANIFEST"; then
continue
fi
if has_marker "$MARKER_ON" "$_file" ||
has_marker "$MARKER_OFF" "$_file"; then
fail "$_file carries a debug marker but is absent from $MANIFEST,
so the manifest no longer describes the emitted bundles."
fi
done <<EOF
$_listing
EOF
}
# The requested mode, read from our own environment using build.js's exact
# rule: only the literal 1 opts in. Deliberately not taken from anything
# build.js records about itself, so build.js cannot vouch for build.js.
expected_marker() {
if [ "${AUTISTMASK_DEBUG-}" = "1" ]; then
echo "$MARKER_ON"
else
echo "$MARKER_OFF"
fi
}
main() {
cd "$ROOT"
expected="$(expected_marker)"
echo "Verifying emitted bundles (expecting $expected)..."
[ -f "$MANIFEST" ] ||
fail "$MANIFEST is missing. build.js writes it at the end of a
successful build; run make build first."
[ -s "$MANIFEST" ] ||
fail "$MANIFEST is empty, so no emitted bundle was found to contain
src/shared/constants.js. That is never correct, so it is a failure and not
a pass."
count=0
while read -r file; do
[ -n "$file" ] || continue
[ -f "$file" ] ||
fail "$MANIFEST lists $file, which does not exist."
read_marker "$file"
[ "$MARKER" = "$expected" ] ||
fail "$file is $MARKER but this build expects $expected."
echo " ok: $file ($MARKER)"
count=$((count + 1))
done <"$MANIFEST"
[ "$count" -gt 0 ] || fail "no bundles were inspected."
check_unlisted_bundles
echo "verify-build: $count bundle(s) verified $expected"
}
main "$@"

View File

@@ -1,161 +0,0 @@
#!/usr/bin/env node
// Apply enrichment data to tokenList.js: add name + url fields to each token.
import { readFileSync, writeFileSync } from "fs";
const enrichment = JSON.parse(
readFileSync("scripts/token-enrichment.json", "utf8"),
);
// Well-known URLs for major tokens not in ethereum-lists.
// These are official project websites verified from public sources.
const EXTRA_URLS = {
"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2": "https://weth.io",
"0xdAC17F958D2ee523a2206206994597C13D831ec7": "https://tether.to",
"0xB8c77482e45F1F44dE1745F52C74426C631bDD52": "https://www.bnbchain.org",
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48": "https://www.circle.com/usdc",
"0xdC035D45d973E3EC169d2276DDab16f1e407384F": "https://sky.money",
"0x2AF5D2aD76741191D15Dfe7bF6aC92d4Bd912Ca3": "https://www.bitfinex.com",
"0x514910771AF9Ca656af840dff83E8264EcF986CA": "https://chain.link",
"0x4c9EDD5852cd905f086C759E8383e09bff1E68B3": "https://ethena.fi",
"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d":
"https://www.worldlibertyfinancial.com",
"0x6B175474E89094C44Da98b954EedeAC495271d0F": "https://makerdao.com",
"0x6c3ea9036406852006290770BEdFcAbA0e23A0e8":
"https://www.paypal.com/us/digital-wallet/manage-money/crypto/pyusd",
"0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE": "https://shibatoken.com",
"0xdA5e1988097297dCdc1f90D4dFE7909e847CBeF6":
"https://www.worldlibertyfinancial.com",
"0xA0b73E1Ff0B80914AB6fe0444E65848C4C34450b": "https://crypto.com",
"0x582d872A1B094FC48F5DE31D3B73F2D9bE47def1": "https://ton.org",
"0x68749665FF8D2d112Fa859AA293F07A622782F38": "https://gold.tether.to",
"0x196C20DA81Fbc324EcdF55501e95Ce9f0bD84d14": "https://polkadot.network",
"0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984": "https://uniswap.org",
"0x45804880De22913dAFE09f4980848ECE6EcbAf78":
"https://www.paxos.com/paxgold",
"0x3c3a81e81dc49A522A592e7622A7E711c06bf354": "https://www.mantle.xyz",
"0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9": "https://aave.com",
"0x75231F58b43240C9718Dd58B4967c5114342a86c": "https://www.okx.com",
"0x6982508145454Ce325dDbE47a25d4ec3d2311933": "https://www.pepe.vip",
"0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599": "https://wbtc.network",
"0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84": "https://lido.fi",
"0x5A98FcBEA516Cf06857215779Fd812CA3beF1B32": "https://lido.fi",
"0xC18360217D8F7Ab5e7c516566761Ea12Ce7F9D72": "https://ens.domains",
"0x7D1AfA7B718fb893dB30A3aBc0Cfc608AaCfeBB0": "https://polygon.technology",
"0x9f8F72aA9304c8B593d555F12eF6589cC3A579A2": "https://makerdao.com",
"0xD533a949740bb3306d119CC777fa900bA034cd52": "https://curve.fi",
"0x111111111117dC0aa78b770fA6A738034120C302": "https://1inch.io",
"0x163f8C2467924be0ae7B5347228CABF260318753": "https://worldcoin.org",
"0xba100000625a3754423978a60c9317c58a424e3D": "https://balancer.fi",
"0xC011a73ee8576Fb46F5E1c5751cA3B9Fe0af2a6F": "https://synthetix.io",
"0x1a7e4e63778B4f12a199C062f3eFdD288afCBce8": "https://www.euler.finance",
"0xBBbbCA6A901c926F240b89EacB641d8Aec7AEafD": "https://loopring.org",
"0xC669928185DbCE49d2230CC9B0979BE6DC797957": "https://bitstable.finance",
"0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0": "https://lido.fi",
"0xBe9895146f7AF43049ca1c1AE358B0541Ea49704":
"https://www.coinbase.com/cbeth",
"0xfAbA6f8e4a5E8Ab82F62fe7C39859FA577269BE3": "https://ondo.finance",
"0x0C10bF8FcB7Bf5412187A595ab97a3609160b5c6": "https://decentraland.org",
"0x6De037ef9aD2725EB40118Bb1702EBb27e4Aeb24": "https://www.render.com",
"0x4a220E6096B25EADb88358cb44068A3248254675": "https://www.quant.network",
"0xba5BDe662c17e2aDFF1075610382B9B691296350": "https://superrare.com",
"0x15D4c048F83bd7e37d49eA4C83a07267Ec4203dA": "https://galxe.com",
"0xc00e94Cb662C3520282E6f5717214004A7f26888": "https://compound.finance",
"0xE41d2489571d322189246DaFA5ebDe1F4699F498": "https://0x.org",
"0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e": "https://yearn.fi",
"0x6fB3e0A217407EFFf7Ca062D46c26E5d60a14d69": "https://iotex.io",
"0xD31a59c85aE9D8edEFec411D448f90841571b89c": "https://solana.com",
"0xa1faa113cbE53436Df28FF0aEe54275c13B40975": "https://alphafinance.io",
"0xF57e7e7C23978C3cAEC3C3548E3D615c346e79fF": "https://immutable.com",
"0x0f5D2fB29fb7d3CFeE444a200298f468908cC942": "https://decentraland.org",
"0x3845badAde8e6dFF049820680d1F14bD3903a5d0": "https://www.sandbox.game",
"0x4d224452801ACEd8B2F0aebE155379bb5D594381": "https://apecoin.com",
"0x6810e776880C02933D47DB1b9fc05908e5386b96": "https://gnosis.io",
"0x5f98805A4E8be255a32880FDeC7F6728C6568bA0": "https://www.liquity.org",
"0xd26114cd6EE289AccF82350c8d8487fedB8A0C07": "https://omg.network",
"0x0D8775F648430679A709E98d2b0Cb6250d2887EF":
"https://basicattentiontoken.org",
"0xc944E90C64B2c07662A292be6244BDf05Cda44a7": "https://thegraph.com",
"0x1985365e9f78359a9B6AD760e32412f4a445E862": "https://augur.net",
"0x4E15361FD6b4BB609Fa63C81A2be19d873717870": "https://fantom.foundation",
"0x853d955aCEf822Db058eb8505911ED77F175b99e": "https://frax.finance",
"0x0000000000085d4780B73119b644AE5ecd22b376": "https://www.trueusd.com",
"0x0cEC1A9154Ff802e7934Fc916Ed7Ca50bDE6844e": "https://polymesh.network",
"0x956F47F50A910163D8BF957Cf5846D573E7f87CA": "https://fei.money",
"0x3432B6A60D23Ca0dFCa7761B7ab56459D9C964D0": "https://frax.finance",
"0xf939E0A03FB07F59A73314E73794Be0E57ac1b4E": "https://curve.fi",
"0x0100546F2cD4C9D97f798fFC9755E47865FF7Ee6": "https://alchemix.fi",
"0x808507121B80c02388fAd14726482e061B8da827": "https://pendle.finance",
"0xCa14007Eff0dB1f8135f4C25B34De49AB0d42766": "https://starknet.io",
"0x8457CA5040ad67fdebbCC8EdCE889A335Bc0fbFB": "https://alt.layer",
"0x4C19596f5aAfF459fA38B0f7eD92F11AE6543784": "https://www.truflation.com",
"0xB50721BCf8d664c30412Cfbc6cf7a15145234ad1": "https://arbitrum.io",
"0x912CE59144191C1204E64559FE8253a0e49E6548": "https://arbitrum.io",
"0x6985884C4392D348587B19cb9eAAf157F13271cd": "https://layerzero.network",
"0xFe0c30065B384F05761f15d0CC899D4F9F9Cc0eB": "https://ethfi.org",
"0x18084fbA666a33d37592fA2633fD49a74DD93a88": "https://tbtc.network",
"0x9D39A5DE30e57443BfF2A8307A4256c8797A3497": "https://suilend.fi",
"0x5283D291DBCF85356A21bA090E6db59121208b44": "https://blur.io",
"0x43Dfc4159D86F3A37A5A4B3D4580b888ad7d4DDd": "https://dydx.exchange",
"0xCf0C122c6b73ff809C693DB761e7BaeBe62b6a2E": "https://floki.com",
"0xe53EC727dbDEB9E2d5456c3be40cFF031AB40A55": "https://superfarm.com",
"0x32353A6C91143bfd6C7d363B546e62a9A2489A20": "https://www.agora.finance",
"0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c": "https://www.euro-coin.com",
"0x1a03eF3d1a80b5f214e468ACD97F55714E049393": "https://gemspad.io",
"0x7122985656e38BDC0302Db86685bb972b145bD3C": "https://stone.fi",
"0xa35923162C49cF95e6BF26623385eb431ad920D3": "https://www.turbo-eth.com",
"0xdBdb4d16EdA451D0503b854CF79D55697F90c8DF": "https://alchemix.fi",
"0x08c32b0726C5684024ea6e141C50aDe9690bBdcc": "https://stratis.io",
"0x65Ef703f5594D2573eb71Aaf55BC0CB548492df4": "https://multichain.org",
"0x1151CB3d861920e07a38e03eEAd12C32178567F6": "https://bonk.com",
"0x090185f2135308BaD17527004364eBcC2D37e5F6": "https://spell.club",
"0xAf5191B0De278C7286d6C7CC6ab6BB8A73bA2Cd6": "https://stargate.finance",
};
// Apply extra URLs where enrichment has none
for (const [addr, url] of Object.entries(EXTRA_URLS)) {
const e = enrichment[addr];
if (e && !e.url) {
e.url = url;
}
}
// Read original tokenList.js
const original = readFileSync("src/shared/tokenList.js", "utf8");
// Replace each token block: add name and url after decimals
let result = original;
let replacements = 0;
for (const [addr, data] of Object.entries(enrichment)) {
// Escape special regex characters in the address
const escaped = addr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// Match the token block: { address: "...", symbol: "...", decimals: N, }
// The closing }, may have trailing whitespace
const blockRe = new RegExp(
`(\\{\\s*address:\\s*"${escaped}",\\s*symbol:\\s*"[^"]+",\\s*decimals:\\s*\\d+),?(\\s*\\})`,
);
const match = result.match(blockRe);
if (!match) {
console.error(`WARNING: Could not find block for ${addr}`);
continue;
}
// Build replacement: original fields + name + url
const nameEscaped = data.name.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const urlEscaped = data.url.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const replacement =
match[1] +
`,\n name: "${nameEscaped}",` +
`\n url: "${urlEscaped}",` +
match[2];
result = result.replace(blockRe, replacement);
replacements++;
}
console.error(`Applied ${replacements} enrichments to tokenList.js`);
writeFileSync("src/shared/tokenList.js", result);
console.error("Done!");

View File

@@ -1,87 +0,0 @@
#!/usr/bin/env node
// Temporary script: fetch name + homepage URL for every token in tokenList.js
// Phase 1: CoinGecko /coins/list (1 bulk call) for names — gets 100%
// Phase 2: ethereum-lists GitHub repo for project URLs — gets ~15%
import { readFileSync, writeFileSync } from "fs";
const src = readFileSync("src/shared/tokenList.js", "utf8");
const tokens = [];
const re =
/\{\s*address:\s*"(0x[a-fA-F0-9]{40})",\s*symbol:\s*"([^"]+)",\s*decimals:\s*(\d+)/g;
let m;
while ((m = re.exec(src)) !== null) {
tokens.push({ address: m[1], symbol: m[2], decimals: parseInt(m[3]) });
}
console.error(`Parsed ${tokens.length} tokens`);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// --- Phase 1: bulk names from CoinGecko ---
console.error("Fetching CoinGecko coins list...");
const listResp = await fetch(
"https://api.coingecko.com/api/v3/coins/list?include_platform=true",
);
if (!listResp.ok) throw new Error(`CoinGecko list HTTP ${listResp.status}`);
const coins = await listResp.json();
const addrMap = new Map();
for (const coin of coins) {
const eth = (coin.platforms?.ethereum || "").toLowerCase().trim();
if (eth && eth.startsWith("0x")) {
addrMap.set(eth, { name: coin.name, id: coin.id });
}
}
console.error(`CoinGecko map: ${addrMap.size} Ethereum tokens`);
const enriched = new Map();
for (const t of tokens) {
const info = addrMap.get(t.address.toLowerCase());
enriched.set(t.address, { name: info?.name || t.symbol, url: "" });
}
console.error(
`Matched ${[...enriched.values()].filter((e) => e.name).length}/${tokens.length} names`,
);
// --- Phase 2: ethereum-lists GitHub for URLs ---
console.error("Fetching URLs from ethereum-lists GitHub...");
const CONCURRENT = 10;
let urlsFound = 0;
for (let i = 0; i < tokens.length; i += CONCURRENT) {
const batch = tokens.slice(i, i + CONCURRENT);
await Promise.allSettled(
batch.map(async (t) => {
try {
const r = await fetch(
`https://raw.githubusercontent.com/ethereum-lists/tokens/master/tokens/eth/${t.address}.json`,
);
if (!r.ok) return;
const data = await r.json();
const website = (data.website || "").trim();
if (website && website !== "https://") {
enriched.get(t.address).url = website;
urlsFound++;
}
} catch {
// ignore
}
}),
);
if (i % 100 === 0) {
console.error(
` GitHub: ${Math.min(i + CONCURRENT, tokens.length)}/${tokens.length}, ${urlsFound} URLs`,
);
}
await sleep(200);
}
console.error(`Phase 2 done: ${urlsFound} URLs from ethereum-lists`);
// --- Output ---
const output = {};
for (const [addr, e] of enriched) {
output[addr] = { name: e.name, url: e.url };
}
writeFileSync("scripts/token-enrichment.json", JSON.stringify(output, null, 2));
console.error(`Done! ${tokens.length} names, ${urlsFound} URLs.`);

File diff suppressed because it is too large Load Diff

View File

@@ -1002,6 +1002,64 @@
</p> </p>
<div id="settings-denied-sites"></div> <div id="settings-denied-sites"></div>
</div> </div>
<div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">About</h3>
<p class="text-xs mb-2">
<a
href="https://git.eeqj.de/sneak/AutistMask"
class="underline decoration-dashed"
target="_blank"
rel="noopener noreferrer"
>AutistMask</a
>
— Minimal Ethereum wallet browser extension.
</p>
<div class="text-xs">
<div class="mb-1">
<span class="text-muted">License:</span>
<span id="about-license"></span>
</div>
<div class="mb-1">
<span class="text-muted">Author:</span>
<span id="about-author"></span>
</div>
<div class="mb-1">
<span class="text-muted">Version:</span>
<span
id="about-version"
class="cursor-pointer select-none"
></span>
</div>
<div class="mb-1">
<span class="text-muted">Release date:</span>
<span id="about-release-date"></span>
</div>
<div>
<span class="text-muted">Commit:</span>
<a
id="about-commit-link"
class="underline decoration-dashed"
target="_blank"
rel="noopener noreferrer"
></a>
</div>
</div>
</div>
<div
id="settings-debug-well"
class="bg-well p-3 mx-1 mb-3"
style="display: none"
>
<h3 class="font-bold mb-1">Debug</h3>
<label
class="text-xs flex items-center gap-1 cursor-pointer"
>
<input type="checkbox" id="settings-debug-mode" />
Enable debug mode
</label>
</div>
</div> </div>
<!-- ============ DELETE WALLET CONFIRM ============ --> <!-- ============ DELETE WALLET CONFIRM ============ -->

View File

@@ -1,18 +1,14 @@
// AutistMask popup entry point. // AutistMask popup entry point.
// Loads state, initializes views, triggers first render. // Loads state, initializes views, triggers first render.
const { DEBUG } = require("../shared/constants"); const { state, saveState, loadState } = require("../shared/state");
const { const { setRuntimeDebug } = require("../shared/log");
state,
saveState,
loadState,
currentNetwork,
} = require("../shared/state");
const { refreshPrices } = require("../shared/prices"); const { refreshPrices } = require("../shared/prices");
const { refreshBalances } = require("../shared/balances"); const { refreshBalances } = require("../shared/balances");
const { const {
$, $,
showView, showView,
updateDebugBanner,
setRenderMain, setRenderMain,
pushCurrentView, pushCurrentView,
goBack, goBack,
@@ -209,21 +205,11 @@ async function init() {
await loadState(); await loadState();
applyTheme(state.theme); applyTheme(state.theme);
const net = currentNetwork(); // Sync runtime debug flag from persisted state before first render
if (DEBUG || net.isTestnet) { setRuntimeDebug(state.debugMode);
const banner = document.createElement("div");
banner.id = "debug-banner"; // Create the debug/testnet banner if needed (uses runtime debug state)
if (DEBUG && net.isTestnet) { updateDebugBanner();
banner.textContent = "DEBUG / INSECURE [TESTNET]";
} else if (net.isTestnet) {
banner.textContent = "[TESTNET]";
} else {
banner.textContent = "DEBUG / INSECURE";
}
banner.style.cssText =
"background:#c00;color:#fff;text-align:center;font-size:10px;padding:1px 0;font-family:monospace;position:sticky;top:0;z-index:9999;";
document.body.prepend(banner);
}
// Auto-default active address // Auto-default active address
if ( if (

View File

@@ -10,7 +10,7 @@
--color-border: #000000; --color-border: #000000;
--color-border-light: #cccccc; --color-border-light: #cccccc;
--color-hover: #eeeeee; --color-hover: #eeeeee;
--color-well: #f5f5f5; --color-well: #e8e8e8;
--color-danger-well: #fef2f2; --color-danger-well: #fef2f2;
--color-section: #dddddd; --color-section: #dddddd;
} }

View File

@@ -1,4 +1,4 @@
const { $, showFlash, goBack } = require("./helpers"); const { $, showView, showFlash, goBack } = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList"); const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances"); const { lookupTokenInfo } = require("../../shared/balances");

View File

@@ -1,6 +1,6 @@
// Shared DOM helpers used by all views. // Shared DOM helpers used by all views.
const { DEBUG } = require("../../shared/constants"); const { isDebug } = require("../../shared/log");
const { const {
formatUsd, formatUsd,
getPrice, getPrice,
@@ -59,19 +59,37 @@ function showView(name) {
clearFlash(); clearFlash();
state.currentView = name; state.currentView = name;
saveState(); saveState();
updateDebugBanner(name);
}
// Create or update the debug/insecure warning banner.
// Called on every view switch and after the settings debug toggle changes.
// The banner is shown when the compile-time DEBUG constant is true OR when
// the user has enabled runtime debug mode via the settings easter egg, OR
// when the active network is a testnet.
function updateDebugBanner(viewName) {
const debug = isDebug();
const net = currentNetwork(); const net = currentNetwork();
if (DEBUG || net.isTestnet) { const show = debug || net.isTestnet;
const banner = document.getElementById("debug-banner"); let banner = document.getElementById("debug-banner");
if (banner) { if (show) {
if (DEBUG && net.isTestnet) { if (!banner) {
banner.textContent = banner = document.createElement("div");
"DEBUG / INSECURE [TESTNET] (" + name + ")"; banner.id = "debug-banner";
} else if (net.isTestnet) { banner.style.cssText =
banner.textContent = "[TESTNET]"; "background:#c00;color:#fff;text-align:center;font-size:10px;padding:1px 0;font-family:monospace;position:sticky;top:0;z-index:9999;";
} else { document.body.prepend(banner);
banner.textContent = "DEBUG / INSECURE (" + name + ")";
}
} }
const suffix = viewName ? " (" + viewName + ")" : "";
if (debug && net.isTestnet) {
banner.textContent = "DEBUG / INSECURE [TESTNET]" + suffix;
} else if (net.isTestnet) {
banner.textContent = "[TESTNET]" + suffix;
} else {
banner.textContent = "DEBUG / INSECURE" + suffix;
}
} else if (banner) {
banner.remove();
} }
} }
@@ -417,6 +435,7 @@ module.exports = {
showError, showError,
hideError, hideError,
showView, showView,
updateDebugBanner,
setRenderMain, setRenderMain,
pushCurrentView, pushCurrentView,
goBack, goBack,

View File

@@ -1,8 +1,10 @@
const { const {
$, $,
showView, showView,
updateDebugBanner,
showFlash, showFlash,
escapeHtml, escapeHtml,
flashCopyFeedback,
goBack, goBack,
pushCurrentView, pushCurrentView,
} = require("./helpers"); } = require("./helpers");
@@ -10,12 +12,23 @@ const { applyTheme } = require("../theme");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { NETWORKS, SUPPORTED_CHAIN_IDS } = require("../../shared/networks"); const { NETWORKS, SUPPORTED_CHAIN_IDS } = require("../../shared/networks");
const { onChainSwitch } = require("../../shared/chainSwitch"); const { onChainSwitch } = require("../../shared/chainSwitch");
const { log, debugFetch } = require("../../shared/log"); const { log, debugFetch, setRuntimeDebug } = require("../../shared/log");
const deleteWallet = require("./deleteWallet"); const deleteWallet = require("./deleteWallet");
const {
BUILD_VERSION,
BUILD_LICENSE,
BUILD_AUTHOR,
BUILD_COMMIT,
BUILD_DATE,
GITEA_COMMIT_URL,
} = require("../../shared/buildInfo");
const runtime = const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime; typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
let versionClickCount = 0;
let versionClickTimer = null;
function renderSiteList(containerId, siteMap, stateKey) { function renderSiteList(containerId, siteMap, stateKey) {
const container = $(containerId); const container = $(containerId);
const hostnames = [...new Set(Object.values(siteMap).flat())]; const hostnames = [...new Set(Object.values(siteMap).flat())];
@@ -142,6 +155,28 @@ function show() {
renderSiteLists(); renderSiteLists();
renderWalletListSettings(); renderWalletListSettings();
// Populate About well
$("about-license").textContent = BUILD_LICENSE;
// Show only the name part of the author field (strip email)
const authorName = BUILD_AUTHOR.replace(/\s*<[^>]+>/, "");
$("about-author").textContent = authorName;
$("about-version").textContent = BUILD_VERSION;
$("about-release-date").textContent = BUILD_DATE;
$("about-commit-link").textContent = BUILD_COMMIT;
$("about-commit-link").href = GITEA_COMMIT_URL;
// Reset version click counter each time settings opens
versionClickCount = 0;
// Show debug well if debug mode is already enabled
const debugWell = $("settings-debug-well");
if (state.debugMode) {
debugWell.style.display = "";
} else {
debugWell.style.display = "none";
}
$("settings-debug-mode").checked = state.debugMode;
showView("settings"); showView("settings");
} }
@@ -289,6 +324,66 @@ function init(ctx) {
ctx.showSettingsAddTokenView, ctx.showSettingsAddTokenView,
); );
// Bright saturated colors for easter egg flashes (clicks 610)
const easterEggColors = [
"#ff0055", // hot pink
"#00cc44", // vivid green
"#3366ff", // electric blue
"#ff9900", // bright orange
"#aa00ff", // vivid purple
];
// Easter egg: click version 10 times to reveal the debug well.
// Each click does a copy-flash animation. After 5 clicks, each
// additional click flashes a different bright saturated color.
$("about-version").addEventListener("click", () => {
versionClickCount++;
clearTimeout(versionClickTimer);
// Reset counter if user stops clicking for 3 seconds
versionClickTimer = setTimeout(() => {
versionClickCount = 0;
}, 3000);
const el = $("about-version");
if (versionClickCount > 5) {
// Colored flash for clicks 610
const colorIdx = versionClickCount - 6;
const color = easterEggColors[colorIdx % easterEggColors.length];
el.classList.remove("copy-flash-fade");
el.style.backgroundColor = color;
el.style.color = "#ffffff";
setTimeout(() => {
el.style.backgroundColor = "";
el.style.color = "";
el.classList.add("copy-flash-fade");
setTimeout(() => {
el.classList.remove("copy-flash-fade");
}, 275);
}, 75);
} else {
// Standard copy-flash for clicks 15
flashCopyFeedback(el);
}
if (versionClickCount >= 10) {
versionClickCount = 0;
clearTimeout(versionClickTimer);
$("settings-debug-well").style.display = "";
}
});
// Debug mode toggle — update runtime flag, persist, and re-render banner
$("settings-debug-mode").addEventListener("change", async () => {
state.debugMode = $("settings-debug-mode").checked;
setRuntimeDebug(state.debugMode);
await saveState();
updateDebugBanner(state.currentView);
});
// Sync runtime debug flag on init
setRuntimeDebug(state.debugMode);
$("btn-settings-back").addEventListener("click", () => { $("btn-settings-back").addEventListener("click", () => {
goBack(); goBack();
}); });

View File

@@ -7,6 +7,7 @@ const {
showFlash, showFlash,
flashCopyFeedback, flashCopyFeedback,
addressTitle, addressTitle,
addressDotHtml,
escapeHtml, escapeHtml,
isoDate, isoDate,
timeAgo, timeAgo,

35
src/shared/buildInfo.js Normal file
View File

@@ -0,0 +1,35 @@
// Build-time constants injected by esbuild define in build.js.
// These globals are replaced at bundle time with string literals.
/* global __BUILD_VERSION__, __BUILD_LICENSE__, __BUILD_AUTHOR__,
__BUILD_COMMIT__, __BUILD_COMMIT_FULL__, __BUILD_DATE__ */
const BUILD_VERSION =
typeof __BUILD_VERSION__ !== "undefined" ? __BUILD_VERSION__ : "dev";
const BUILD_LICENSE =
typeof __BUILD_LICENSE__ !== "undefined" ? __BUILD_LICENSE__ : "GPL-3.0";
const BUILD_AUTHOR =
typeof __BUILD_AUTHOR__ !== "undefined"
? __BUILD_AUTHOR__
: "sneak <sneak@sneak.berlin>";
const BUILD_COMMIT =
typeof __BUILD_COMMIT__ !== "undefined" ? __BUILD_COMMIT__ : "unknown";
const BUILD_COMMIT_FULL =
typeof __BUILD_COMMIT_FULL__ !== "undefined"
? __BUILD_COMMIT_FULL__
: "unknown";
const BUILD_DATE =
typeof __BUILD_DATE__ !== "undefined" ? __BUILD_DATE__ : "unknown";
const GITEA_COMMIT_URL =
"https://git.eeqj.de/sneak/AutistMask/commit/" + BUILD_COMMIT_FULL;
module.exports = {
BUILD_VERSION,
BUILD_LICENSE,
BUILD_AUTHOR,
BUILD_COMMIT,
BUILD_COMMIT_FULL,
BUILD_DATE,
GITEA_COMMIT_URL,
};

View File

@@ -1,4 +1,27 @@
const DEBUG = true; // DEBUG is a build-time constant injected by esbuild's define in build.js
// (see src/shared/buildInfo.js for the same pattern). It is false unless the
// bundle was produced with AUTISTMASK_DEBUG=1, and it is false whenever the
// module is loaded outside a bundle (tests, plain require). It must never be
// derived from anything the user can change at runtime: it is what gates the
// hardcoded test mnemonic below.
/* global __BUILD_DEBUG__ */
const DEBUG = typeof __BUILD_DEBUG__ !== "undefined" ? __BUILD_DEBUG__ : false;
// Machine-readable record of the compiled DEBUG state, read out of the emitted
// bundles by script/verify-build. It is derived from DEBUG itself so the two
// cannot disagree, and it is a plain string literal rather than a minifier
// artifact like `DEBUG:!1`, so the check does not depend on esbuild's output
// staying byte-stable across versions.
//
// The ambiguity is the point. When DEBUG is known at build time the bundler
// folds this to exactly one of the two literals. When it is not — which is
// exactly what happens if the __BUILD_DEBUG__ define goes missing from
// build.js — the ternary survives, both literals appear in the bundle, and
// verify-build fails rather than guessing.
const BUILD_DEBUG_MARKER = DEBUG
? "autistmask-build-debug=on"
: "autistmask-build-debug=off";
const DEBUG_MNEMONIC = const DEBUG_MNEMONIC =
"cube evolve unfold result inch risk jealous skill hotel bulb night wreck"; "cube evolve unfold result inch risk jealous skill hotel bulb night wreck";
@@ -36,6 +59,7 @@ function isBurnAddress(address) {
module.exports = { module.exports = {
DEBUG, DEBUG,
BUILD_DEBUG_MARKER,
DEBUG_MNEMONIC, DEBUG_MNEMONIC,
ETHEREUM_MAINNET_CHAIN_ID, ETHEREUM_MAINNET_CHAIN_ID,
ETHEREUM_SEPOLIA_CHAIN_ID, ETHEREUM_SEPOLIA_CHAIN_ID,

View File

@@ -1,12 +1,27 @@
// Leveled logger. Outputs to console with [AutistMask] prefix. // Leveled logger. Outputs to console with [AutistMask] prefix.
// Level is DEBUG when the DEBUG constant is true, INFO otherwise. // Level is DEBUG when the compile-time DEBUG constant is true or the runtime
// debugMode state flag is enabled. The runtime flag is checked lazily so it
// responds immediately when toggled in settings.
const { DEBUG } = require("./constants"); const { DEBUG } = require("./constants");
const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }; const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
const threshold = DEBUG ? LEVELS.debug : LEVELS.info;
// Runtime debug mode flag — set by settings.js when the user toggles debug
// mode via the easter egg. Kept here as a simple mutable reference so it can
// be updated without circular dependency issues with state.js.
let _runtimeDebug = false;
function setRuntimeDebug(enabled) {
_runtimeDebug = enabled;
}
function isDebug() {
return DEBUG || _runtimeDebug;
}
function emit(level, method, args) { function emit(level, method, args) {
const threshold = isDebug() ? LEVELS.debug : LEVELS.info;
if (LEVELS[level] >= threshold) { if (LEVELS[level] >= threshold) {
console[method]("[AutistMask]", ...args); console[method]("[AutistMask]", ...args);
} }
@@ -37,4 +52,4 @@ async function debugFetch(url, opts) {
return resp; return resp;
} }
module.exports = { log, debugFetch }; module.exports = { log, debugFetch, setRuntimeDebug, isDebug };

View File

@@ -29,6 +29,7 @@ const DEFAULT_STATE = {
fraudContracts: [], fraudContracts: [],
tokenHolderCache: {}, tokenHolderCache: {},
theme: "system", theme: "system",
debugMode: false,
}; };
const state = { const state = {
@@ -68,6 +69,7 @@ async function saveState() {
fraudContracts: state.fraudContracts, fraudContracts: state.fraudContracts,
tokenHolderCache: state.tokenHolderCache, tokenHolderCache: state.tokenHolderCache,
theme: state.theme, theme: state.theme,
debugMode: state.debugMode,
currentView: state.currentView, currentView: state.currentView,
selectedWallet: state.selectedWallet, selectedWallet: state.selectedWallet,
selectedAddress: state.selectedAddress, selectedAddress: state.selectedAddress,
@@ -128,6 +130,8 @@ async function loadState() {
state.fraudContracts = saved.fraudContracts || []; state.fraudContracts = saved.fraudContracts || [];
state.tokenHolderCache = saved.tokenHolderCache || {}; state.tokenHolderCache = saved.tokenHolderCache || {};
state.theme = saved.theme || "system"; state.theme = saved.theme || "system";
state.debugMode =
saved.debugMode !== undefined ? saved.debugMode : false;
state.currentView = saved.currentView || null; state.currentView = saved.currentView || null;
state.selectedWallet = state.selectedWallet =
saved.selectedWallet !== undefined ? saved.selectedWallet : null; saved.selectedWallet !== undefined ? saved.selectedWallet : null;

View File

@@ -5,6 +5,10 @@ const { Mnemonic, HDNodeWallet, Wallet } = require("ethers");
const { DEBUG, DEBUG_MNEMONIC, BIP44_ETH_PATH } = require("./constants"); const { DEBUG, DEBUG_MNEMONIC, BIP44_ETH_PATH } = require("./constants");
function generateMnemonic() { function generateMnemonic() {
// This must stay the compile-time DEBUG constant. Do NOT switch it to
// isDebug() from log.js: that also ORs in the runtime debugMode flag the
// settings toggle drives, which would let a user of a release build turn
// the hardcoded, publicly known test phrase back on for real wallets.
if (DEBUG) return DEBUG_MNEMONIC; if (DEBUG) return DEBUG_MNEMONIC;
const m = Mnemonic.fromEntropy( const m = Mnemonic.fromEntropy(
globalThis.crypto.getRandomValues(new Uint8Array(16)), globalThis.crypto.getRandomValues(new Uint8Array(16)),

View File

@@ -1,4 +1,6 @@
const { const {
DEBUG,
BUILD_DEBUG_MARKER,
ETHEREUM_MAINNET_CHAIN_ID, ETHEREUM_MAINNET_CHAIN_ID,
DEFAULT_RPC_URL, DEFAULT_RPC_URL,
BIP44_ETH_PATH, BIP44_ETH_PATH,
@@ -19,6 +21,24 @@ describe("constants", () => {
expect(BIP44_ETH_PATH).toBe("m/44'/60'/0'/0"); expect(BIP44_ETH_PATH).toBe("m/44'/60'/0'/0");
}); });
// This does not replace script/verify-build, which is the only thing that
// can see the compiled DEBUG state of a real bundle. It pins the source
// invariant that the marker tracks DEBUG, so the two cannot be edited
// apart and leave verify-build asserting something that is no longer the
// flag the code branches on.
test("build debug marker is derived from DEBUG", () => {
expect(BUILD_DEBUG_MARKER).toBe(
DEBUG ? "autistmask-build-debug=on" : "autistmask-build-debug=off",
);
});
// Outside a bundle there is no __BUILD_DEBUG__ define, and the fallback
// must be the safe one.
test("DEBUG is off when loaded outside a bundle", () => {
expect(DEBUG).toBe(false);
expect(BUILD_DEBUG_MARKER).toBe("autistmask-build-debug=off");
});
test("exports ERC-20 ABI with expected functions", () => { test("exports ERC-20 ABI with expected functions", () => {
expect(Array.isArray(ERC20_ABI)).toBe(true); expect(Array.isArray(ERC20_ABI)).toBe(true);
expect(ERC20_ABI.length).toBeGreaterThan(0); expect(ERC20_ABI.length).toBeGreaterThan(0);

289
tests/e2e/harness.js Normal file
View File

@@ -0,0 +1,289 @@
// End-to-end harness: launches a real Chromium with the unpacked MV3
// build loaded, collects every uncaught page error and console.error, and
// exposes the popup flows the tests drive.
//
// This runs inside the pinned Playwright container; see script/test-e2e.
// It is deliberately NOT part of make check — REPO_POLICIES.md caps
// make test at 20 seconds and a browser suite does not fit.
"use strict";
const fs = require("fs");
const os = require("os");
const path = require("path");
const { chromium } = require("playwright-core");
const { installNetworkStubs } = require("./network");
const REPO_ROOT = path.resolve(__dirname, "..", "..");
const EXT_PATH = path.join(REPO_ROOT, "dist", "chrome");
// Page errors that are known, tracked, and deliberately tolerated. Every
// entry must name the issue that will remove it. This list is the one
// concession in an otherwise zero-tolerance policy: an uncaught error is
// how this harness caught issue #150 in the first place.
const ALLOWED_ERRORS = [
{
// libsodium ships a WASM build and an asm.js fallback. The
// extension CSP (script-src 'self', with no wasm-unsafe-eval)
// refuses the WASM module on every popup load; libsodium catches
// it and falls back to asm.js, so the wallet works. Deciding
// which backend actually ships is issue #182, and this entry gets
// deleted when that lands.
issue: "#182",
pattern: /Refused to compile or instantiate WebAssembly module/,
},
];
function isAllowed(text) {
return ALLOWED_ERRORS.some((a) => a.pattern.test(text));
}
// Collects every uncaught page error, console.error and unstubbed
// request, and hands each one to exactly one reporter.
//
// This deliberately has NO window API. It used to expose mark()/since()
// so a test could ask for "the errors since I started", and that shape
// produced a green run that proved nothing twice over: first the mark
// started after test 1, so everything recorded during launch was
// discarded, then the tail after the final test was never read at all. In
// both cases a record fell outside somebody's window and vanished, which
// is the precise failure this harness exists to prevent.
//
// So there is no window left to fall outside of. take() is the only
// reader and it always takes everything outstanding, so successive takes
// partition the entire record stream with no gaps, and the runner turns
// every record it reads into a failure.
//
// Observation ends when the browser context is closed. Nothing records
// after that — the route handler and the console listeners are gone with
// the context — so there is no post-teardown phase to collect, and this
// class deliberately offers no mechanism pretending to cover one.
class ErrorCollector {
constructor() {
this.entries = [];
this.taken = 0;
}
record(kind, text) {
const line = kind + ": " + String(text).split("\n")[0];
if (isAllowed(line)) return;
this.entries.push(line);
}
// Everything recorded since the previous take(). Never yields a
// record twice and never skips one.
take() {
const out = this.entries.slice(this.taken);
this.taken = this.entries.length;
return out;
}
}
function attachErrorListeners(ctx, errors) {
const attachPage = (page) => {
page.on("pageerror", (err) => {
errors.record("pageerror", err.message || String(err));
});
page.on("console", (msg) => {
if (msg.type() === "error") {
errors.record("console.error", msg.text());
}
});
};
ctx.pages().forEach(attachPage);
ctx.on("page", attachPage);
// Per-page listeners only: the context-level "weberror" event covers
// the same page exceptions and would double-report them. Playwright
// exposes no error EVENT for service workers, so an uncaught
// exception in the background worker is not visible here — everything
// this suite drives lives in the popup page. That is an error-channel
// gap only: worker NETWORK traffic is intercepted and reported like
// any other, and assertWorkerTrafficIntercepted() below fails the run
// if it ever stops being.
}
async function serviceWorker(ctx) {
const [existing] = ctx.serviceWorkers();
if (existing) return existing;
return ctx.waitForEvent("serviceworker", { timeout: 30000 });
}
// How long to wait for the background worker's first outbound request.
//
// The margin that actually decides whether this check is sound is not
// this timeout — it is whether the route handler is installed before the
// worker fetches. Measured over several runs: route installation
// completes 11-23ms after the context comes up, and the worker's
// blocklist fetch arrives 525-883ms after that, so the route wins by
// roughly 25-50x. This 30s figure is only slack for a loaded machine on
// top of that; losing the race fails the run rather than passing it
// quietly, which was verified by forcing a 3s delay before route
// installation.
const WORKER_TRAFFIC_TIMEOUT_MS = 30000;
// ctx.route() only sees service-worker requests when Playwright runs with
// PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1, which script/test-e2e
// sets. Without it the worker's traffic — notably the phishing blocklist
// fetch src/background/index.js issues at startup — goes to the real
// internet, and nothing says so, because src/shared/phishingDomains.js
// swallows fetch failures. A harness whose isolation can lapse in silence
// is worthless, so this does not take the flag on trust: the background
// worker's own startup fetch has to show up in the route handler, or the
// suite refuses to run.
//
// Deliberately NOT a synthetic probe fetched through worker.evaluate():
// evaluating in an extension worker this early kills it (the call fails
// with "Target page, context or browser has been closed" and the worker
// disappears), which would break the very thing being measured. Observing
// traffic the extension already generates costs nothing and cannot
// perturb it.
async function assertWorkerTrafficIntercepted(stubs) {
const seen = await stubs.waitForServiceWorkerTraffic(
WORKER_TRAFFIC_TIMEOUT_MS,
);
if (seen) return seen;
// State the observation, not a conclusion. This fires for at least
// two quite different causes and the harness cannot tell them apart
// from here, so guessing one of them in the message sends the reader
// the wrong way.
throw new Error(
"observed no service-worker request in the route handler within " +
WORKER_TRAFFIC_TIMEOUT_MS +
"ms. Under working interception the background worker's " +
"startup blocklist fetch (src/background/index.js) reaches the " +
"handler about half a second after the route is installed. " +
"Two causes are plausible and this check cannot distinguish " +
"them: (1) service-worker interception is not in effect, so " +
"that traffic went to the real internet unobserved — the suite " +
"must be run through script/test-e2e, which sets " +
"PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1, and a " +
"Playwright upgrade may have dropped or renamed that flag; " +
"(2) no worker request was made in the first place — the route " +
"lost the startup race, or the worker no longer fetches at " +
"startup, in which case this check needs a new anchor because " +
"there is no longer any worker traffic to observe. Either way " +
"the fix is a replacement mechanism or an honest downgrade of " +
"the isolation claims in tests/e2e/network.js and README.md — " +
"not deleting this check",
);
}
async function launch(routeOpts) {
if (!fs.existsSync(path.join(EXT_PATH, "manifest.json"))) {
throw new Error(
"no unpacked build at " +
EXT_PATH +
" — run make build before the e2e suite",
);
}
const userDir = fs.mkdtempSync(path.join(os.tmpdir(), "autistmask-e2e-"));
const ctx = await chromium.launchPersistentContext(userDir, {
// channel: "chromium" is load-bearing. The default headless mode
// uses the headless shell, which silently refuses to load
// extensions: there is no error at all, the service worker simply
// never appears. This cost real debugging time once already.
channel: "chromium",
headless: true,
args: [
"--disable-extensions-except=" + EXT_PATH,
"--load-extension=" + EXT_PATH,
// The container runs unprivileged; Chrome's sandbox needs
// capabilities the harness deliberately does not grant it.
"--no-sandbox",
// Belt to the interception braces: nothing that slips past
// the route handler can resolve a name, so a request that
// escapes cannot actually reach the internet. Detection is
// still assertWorkerTrafficIntercepted()'s job — this only
// bounds the damage while a gap goes unnoticed. Playwright
// fulfils routed requests without touching the resolver, and
// it drives the browser over a pipe, so neither is affected.
"--host-resolver-rules=MAP * ~NOTFOUND",
],
});
const cleanup = async () => {
await ctx.close().catch(() => {});
fs.rmSync(userDir, { recursive: true, force: true });
};
try {
const errors = new ErrorCollector();
attachErrorListeners(ctx, errors);
routeOpts.report = (text) => errors.record("network", text);
const stubs = await installNetworkStubs(ctx, routeOpts);
await assertWorkerTrafficIntercepted(stubs);
// The extension id is derived from the unpacked path, so it
// changes and must never be hardcoded. It is the host part of the
// service worker URL.
const sw = await serviceWorker(ctx);
const id = new URL(sw.url()).host;
return {
ctx,
errors,
extensionId: id,
popupUrl: "chrome-extension://" + id + "/src/popup/index.html",
close: cleanup,
};
} catch (e) {
// Anything that fails after the browser is up has to tear it down
// on the way out: an orphaned context keeps node alive forever,
// turning a clean failure into a hung run.
await cleanup();
throw e;
}
}
// ---------------------------------------------------------------- flows
const PASSWORD = "e2e-harness-password";
async function visible(page, selector, timeout = 15000) {
await page.waitForSelector(selector, { state: "visible", timeout });
}
async function openPopup(ctx, popupUrl) {
const page = await ctx.newPage();
await page.goto(popupUrl);
return page;
}
// Full wallet creation through the real UI: BIP-39 generation, libsodium
// vault encryption and extension storage persistence, for real.
async function createWallet(page) {
await page.click("#btn-welcome-add");
await visible(page, "#view-add-wallet");
await page.click("#btn-generate-phrase");
await page.waitForFunction(() => {
const el = document.getElementById("wallet-mnemonic");
return el && el.value.trim().split(/\s+/).length >= 12;
});
await page.fill("#add-wallet-password", PASSWORD);
await page.fill("#add-wallet-password-confirm", PASSWORD);
await page.click("#btn-add-wallet-confirm");
await visible(page, "#view-main", 60000);
}
// Reach the address detail screen from wherever the popup restored to.
// Clicking .address-row does not open it; the [info] button does.
async function openAddressDetail(page) {
const onAddress = await page.isVisible("#view-address");
if (!onAddress) {
await visible(page, "#view-main");
await page.click("#wallet-list .btn-addr-info");
}
await visible(page, "#view-address");
}
module.exports = {
createWallet,
launch,
openAddressDetail,
openPopup,
visible,
};

334
tests/e2e/network.js Normal file
View File

@@ -0,0 +1,334 @@
// Browser-level network interception for the end-to-end suite.
//
// Every http(s) request the extension makes — from the popup page AND
// from the MV3 background service worker — is fulfilled from these
// fixtures, so the suite is deterministic and runs entirely offline. The
// probe that motivated this harness (see issue #181) observed live calls
// to Blockscout returning 401 inside the container, which would make any
// assertion about rendered transaction data worthless.
//
// Service-worker coverage is not free: ctx.route() only sees worker
// traffic when PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1 is set in
// the environment, which script/test-e2e does. Without it the phishing
// blocklist fetch that src/background/index.js issues at worker startup
// silently reaches raw.githubusercontent.com on the open internet, and
// src/shared/phishingDomains.js swallows the failure so nothing surfaces
// it. That is not left to trust: waitForServiceWorkerTraffic() below
// backs the launch-time canary in harness.js, which fails the entire
// suite if worker requests stop being visible here.
//
// Anything not explicitly stubbed here is aborted AND reported to the
// error collector, so a newly added outbound call shows up as a test
// failure rather than as intermittent flakiness.
"use strict";
// Fictional ERC-20 used to seed the transaction-detail test. The symbol
// must not collide with any entry in src/shared/tokenList.js, or
// isSpoofedSymbol() in src/shared/transactions.js drops the transfer as a
// symbol-spoofing attempt; holders_count must be >= 1000 or the default
// hideLowHolderTokens filter drops it. Either would make the test pass
// vacuously by never rendering a row at all.
const STUB_TOKEN = {
address: "0xe2e0000000000000000000000000000000000e2e",
symbol: "E2E",
name: "End To End Test Token",
decimals: "6",
holders: "12345",
};
const STUB_COUNTERPARTY = "0xc0ffee0000000000000000000000000000c0ffee";
const STUB_TX_HASH =
"0xe2e0000000000000000000000000000000000000000000000000000000000e2e";
const STUB_BLOCK_NUMBER = 21000000;
// Fixed instant so timeAgo() output is stable across runs.
const STUB_TX_TIMESTAMP = "2026-01-02T03:04:05.000000Z";
// A 32-byte zero word. Returned for every eth_call, which is what makes
// ethers' ENS reverse lookup resolve to "no resolver set" and return null
// instead of throwing. A throw would be logged by src/shared/ens.js via
// log.errorf(), i.e. console.error, which fails the run on its own.
const ZERO_WORD = "0x" + "0".repeat(64);
const RPC_RESULTS = {
eth_chainId: "0x1",
net_version: "1",
eth_blockNumber: "0x1406f40",
eth_getBalance: "0x0",
eth_call: ZERO_WORD,
eth_gasPrice: "0x3b9aca00",
eth_estimateGas: "0x5208",
eth_getTransactionCount: "0x0",
eth_maxPriorityFeePerGas: "0x3b9aca00",
};
function tokenObject() {
return {
address_hash: STUB_TOKEN.address,
address: STUB_TOKEN.address,
symbol: STUB_TOKEN.symbol,
name: STUB_TOKEN.name,
decimals: STUB_TOKEN.decimals,
holders_count: STUB_TOKEN.holders,
type: "ERC-20",
};
}
// One received ERC-20 transfer of 1.5 E2E to the address under test.
function tokenTransferItems(address) {
return [
{
transaction_hash: STUB_TX_HASH,
block_number: STUB_BLOCK_NUMBER,
timestamp: STUB_TX_TIMESTAMP,
from: { hash: STUB_COUNTERPARTY },
to: { hash: address },
total: { decimals: STUB_TOKEN.decimals, value: "1500000" },
token: tokenObject(),
},
];
}
// Full details for STUB_TX_HASH. raw_input is "0x" so the calldata
// decoder short-circuits; the on-chain detail fields still populate.
function transactionDetails() {
return {
hash: STUB_TX_HASH,
block_number: STUB_BLOCK_NUMBER,
nonce: 7,
gas_used: "51000",
gas_price: "1000000000",
fee: { value: "51000000000000" },
raw_input: "0x",
status: "ok",
};
}
function jsonResponse(route, body) {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(body),
});
}
// Extract the address from a Blockscout /addresses/<addr>/... path.
function blockscoutAddress(pathname) {
const m = pathname.match(/\/addresses\/(0x[0-9a-fA-F]{40})\//);
return m ? m[1] : null;
}
function handleRpc(route, postData, report) {
let payload;
try {
payload = JSON.parse(postData || "null");
} catch {
report("unstubbed RPC: unparseable body " + String(postData));
return route.abort();
}
// ethers batches by default, so the body may be an array.
const batch = Array.isArray(payload) ? payload : [payload];
// Anything that is not a JSON-RPC object, or a batch of them, is not
// RPC at all and must be reported like any other unrecognised
// outbound traffic rather than dereferenced. request.postData()
// returns null both for a bodyless POST and for a body Playwright
// cannot decode as UTF-8 (sendBeacon with a Blob, or any binary
// payload), so this is not an empty-string special case: it rejects
// every non-object payload, exactly as the catch above rejects every
// unparseable one.
if (
payload === null ||
typeof payload !== "object" ||
!batch.every((req) => req !== null && typeof req === "object")
) {
report("unstubbed request: POST " + route.request().url());
return route.abort();
}
const replies = batch.map((req) => {
const result = RPC_RESULTS[req.method];
if (result === undefined) {
report("unstubbed RPC method: " + req.method);
return {
jsonrpc: "2.0",
id: req.id,
error: { code: -32601, message: "unstubbed in e2e harness" },
};
}
return { jsonrpc: "2.0", id: req.id, result };
});
return jsonResponse(route, Array.isArray(payload) ? replies : replies[0]);
}
const TRACE_TRUE = ["1", "true", "yes", "on"];
const TRACE_FALSE = ["", "0", "false", "no", "off"];
// Whether E2E_TRACE_NETWORK asks for the request trace.
//
// A set-but-unrecognised value is a hard error rather than a quiet
// "off": E2E_TRACE_NETWORK=true asking for a trace and getting silence
// is the operator being lied to about what the harness is doing, which
// is the whole failure mode this suite exists to eliminate. Refusing to
// guess costs one line and one obvious error message.
function traceEnabled(raw) {
if (raw === undefined || raw === null) return false;
const v = String(raw).trim().toLowerCase();
if (TRACE_TRUE.includes(v)) return true;
if (TRACE_FALSE.includes(v)) return false;
throw new Error(
"E2E_TRACE_NETWORK is set to " +
JSON.stringify(String(raw)) +
", which is not a recognised on/off value. Use one of " +
TRACE_TRUE.join(", ") +
" to enable the request trace, or one of " +
TRACE_FALSE.slice(1).join(", ") +
" to disable it. Refusing to guess: a diagnostic that silently " +
"does nothing is worse than one that is not there",
);
}
/**
* Route every http(s) request through local fixtures.
*
* @param {import("playwright-core").BrowserContext} ctx
* @param {object} opts
* @param {(text: string) => void} opts.report called for unstubbed traffic
* @param {boolean} [opts.seedTokenTransfer] serve the stubbed ERC-20
* transfer. Read at request time, so a test can flip it on the same
* options object without re-registering the route.
* @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) =>
* Promise<string|null>}>}
*/
async function installNetworkStubs(ctx, opts) {
const report = opts.report;
// First request seen that originated in a service worker, and the
// resolver waiting for it. This is what proves worker interception is
// actually in force; see waitForServiceWorkerTraffic below.
let firstWorkerRequest = null;
let announceWorkerRequest = null;
// E2E_TRACE_NETWORK=1 prints every request that reaches this handler,
// tagged [sw] when it originated in the background service worker.
// It exists so the isolation claim above can be re-checked by anyone
// in one command, without editing files: the phishing blocklist fetch
// showing up with an [sw] tag is the proof that the worker really is
// intercepted and that the raw.githubusercontent.com stub below is
// live code rather than decoration.
const trace = traceEnabled(process.env.E2E_TRACE_NETWORK);
// Regex rather than a glob so chrome-extension:// resource loads are
// never touched — routing those would break the popup itself.
await ctx.route(/^https?:\/\//, async (route) => {
const req = route.request();
const url = new URL(req.url());
const p = url.pathname;
const fromWorker = !!req.serviceWorker();
if (fromWorker && !firstWorkerRequest) {
firstWorkerRequest = req.method() + " " + req.url();
if (announceWorkerRequest)
announceWorkerRequest(firstWorkerRequest);
}
if (trace) {
const origin = fromWorker ? "[sw] " : "[page] ";
console.log("# routed " + origin + req.method() + " " + req.url());
}
// JSON-RPC endpoint (any host): a POST with a JSON-RPC body.
if (req.method() === "POST") {
return handleRpc(route, req.postData(), report);
}
// Blockscout v2
if (p.includes("/api/v2/")) {
if (/\/addresses\/0x[0-9a-fA-F]{40}\/transactions$/.test(p)) {
return jsonResponse(route, { items: [] });
}
if (/\/addresses\/0x[0-9a-fA-F]{40}\/token-transfers$/.test(p)) {
const addr = blockscoutAddress(p);
return jsonResponse(route, {
items:
opts.seedTokenTransfer && addr
? tokenTransferItems(addr)
: [],
});
}
if (/\/addresses\/0x[0-9a-fA-F]{40}\/token-balances$/.test(p)) {
return jsonResponse(route, []);
}
if (p.endsWith("/transactions/" + STUB_TX_HASH)) {
return jsonResponse(route, transactionDetails());
}
}
// CoinDesk price tick
if (url.hostname.endsWith("coindesk.com")) {
return jsonResponse(route, { Data: {} });
}
// MetaMask phishing blocklist
if (
url.hostname === "raw.githubusercontent.com" ||
p.endsWith("/eth-phishing-detect/main/src/config.json")
) {
return jsonResponse(route, {
version: 2,
tolerance: 2,
fuzzylist: [],
whitelist: [],
blacklist: [],
});
}
// Best-effort Etherscan address labels: served as an empty page.
if (url.hostname.endsWith("etherscan.io")) {
return route.fulfill({
status: 200,
contentType: "text/html",
body: "<html><body></body></html>",
});
}
report("unstubbed request: " + req.method() + " " + req.url());
return route.abort();
});
return {
/**
* Resolve with the first service-worker-originated request this
* handler saw, or null if none arrives within `ms`.
*
* The background worker fetches the phishing blocklist at
* startup, unconditionally, within about a second of the context
* coming up — so under working interception this resolves almost
* immediately. Nothing arriving means worker traffic is bypassing
* the handler entirely and going to the real internet, which the
* caller turns into a hard failure of the whole suite.
*/
waitForServiceWorkerTraffic(ms) {
if (firstWorkerRequest) return Promise.resolve(firstWorkerRequest);
return new Promise((resolve) => {
const timer = setTimeout(() => {
announceWorkerRequest = null;
resolve(null);
}, ms);
announceWorkerRequest = (req) => {
clearTimeout(timer);
announceWorkerRequest = null;
resolve(req);
};
});
},
};
}
module.exports = {
installNetworkStubs,
STUB_TOKEN,
STUB_TX_HASH,
};

264
tests/e2e/run.js Normal file
View File

@@ -0,0 +1,264 @@
// End-to-end suite entrypoint. Run via script/test-e2e (which builds
// dist/chrome/ and starts the pinned container); running it directly
// requires a Chromium that playwright-core can find.
//
// A plain runner rather than jest on purpose: jest's default testMatch
// would pull these files into script/test, and browser tests do not fit
// inside the 20-second cap REPO_POLICIES.md puts on make test. Nothing
// here is named *.test.js for the same reason.
"use strict";
const {
createWallet,
launch,
openAddressDetail,
openPopup,
visible,
} = require("./harness");
const { STUB_TOKEN, STUB_TX_HASH } = require("./network");
const TEST_TIMEOUT_MS = 120000;
// How long to keep collecting after the final test returns; see the
// trailing drain in main().
const TRAILING_WATCH_MS = 1500;
const tests = [];
function test(name, fn) {
tests.push({ name, fn });
}
function assert(cond, message) {
if (!cond) throw new Error(message);
}
function withTimeout(promise, name) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(
() =>
reject(new Error("timed out after " + TEST_TIMEOUT_MS + "ms")),
TEST_TIMEOUT_MS,
);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
// ----------------------------------------------------------------- tests
test("popup loads and reaches the welcome view", async (env) => {
env.page = await openPopup(env.ctx, env.popupUrl);
await visible(env.page, "#view-welcome");
const title = await env.page.title();
assert(title === "AutistMask", "unexpected popup title: " + title);
});
test("wallet creation through the UI reaches the main view", async (env) => {
await createWallet(env.page);
const addrCount = await env.page
.locator("#wallet-list .btn-addr-info")
.count();
assert(addrCount > 0, "no addresses rendered in the wallet list");
});
test("add token screen opens from address detail (#150)", async (env) => {
await openAddressDetail(env.page);
await env.page.click("#btn-add-token");
await visible(env.page, "#view-add-token");
const quickPicks = await env.page
.locator("#common-token-list .common-token")
.count();
assert(quickPicks > 0, "no common-token quick-pick buttons rendered");
});
test("transaction detail renders an ERC-20 transfer (#151)", async (env) => {
// Serve the stubbed token transfer from here on, then reload so the
// address detail screen refetches its transaction list.
env.routeOpts.seedTokenTransfer = true;
await env.page.reload();
await openAddressDetail(env.page);
await visible(env.page, "#tx-list .tx-row");
const rowText = await env.page
.locator("#tx-list .tx-row")
.first()
.innerText();
assert(
rowText.includes(STUB_TOKEN.symbol),
"token transfer row missing symbol " +
STUB_TOKEN.symbol +
", got: " +
JSON.stringify(rowText),
);
await env.page.locator("#tx-list .tx-row").first().click();
await visible(env.page, "#view-transaction");
const hash = await env.page.locator("#tx-detail-hash").innerText();
assert(
hash.includes(STUB_TX_HASH),
"transaction detail shows the wrong hash: " + hash,
);
// The token contract row is the field that crashes when
// addressDotHtml is not imported: it renders only for transfers with
// a contractAddress, which is every ERC-20 transfer.
await visible(env.page, "#tx-detail-token-contract-section");
const contract = env.page.locator("#tx-detail-token-contract");
const contractText = await contract.innerText();
assert(
contractText.toLowerCase().includes(STUB_TOKEN.address),
"token contract row missing the contract address, got: " +
JSON.stringify(contractText),
);
const dots = await contract.locator('span[style*="border-radius"]').count();
assert(dots > 0, "token contract row rendered without its colour dot");
});
// ---------------------------------------------------------------- runner
async function main() {
// A suite that runs nothing must never report success. If a refactor
// drops the registrations above, or a require() of this file stops
// reaching them, the only honest outcome is a red run — reporting
// "0/0 passed" and exiting 0 is the same vacuous-check failure this
// whole harness exists to prevent.
if (tests.length === 0) {
console.log("1..0");
console.log("# FAILED: the e2e suite registered no tests");
process.exitCode = 1;
return;
}
const routeOpts = { seedTokenTransfer: false };
let session;
try {
session = await launch(routeOpts);
} catch (e) {
// Never skip and report success: a browser we cannot start, or
// one whose network interception is not in force, is a failure of
// the suite, not an absent one.
console.error("e2e: cannot run the suite: " + e.message);
process.exitCode = 1;
return;
}
console.log("# extension id: " + session.extensionId);
console.log("1.." + tests.length);
const env = {
ctx: session.ctx,
popupUrl: session.popupUrl,
routeOpts,
page: null,
};
// Attribution of collected errors is total. session.errors has no
// window API at all: take() always drains everything outstanding, so
// successive takes partition the whole stream, and the phases below
// cover the entire life of the run. Nothing the collector holds can
// go unread.
//
// launch .. end of test 1 -> test 1 (so the worker's startup
// fetches land on a test, not
// nowhere)
// end of test k .. end of k+1 -> test k+1
// last test .. teardown -> the suite, via the trailing drain
//
// Those three phases cover the entire life of the browser context.
// There is no fourth: once the context is closed nothing can record,
// because the route handler and the console listeners died with it.
// Traffic that a test defers past the trailing drain is therefore
// never observed at all — a real limit of this design, stated in the
// README, and not one any post-teardown hook could close.
//
// Two green-but-vacuous runs on this harness were the same shape: a
// record falling outside somebody's window and being dropped. First
// the mark started after test 1, discarding launch-time records;
// then the tail after the last test was never read. Patching a
// second boundary would have invited a third, so the window concept
// is gone rather than fixed.
let failed = 0;
let n = 0;
for (const t of tests) {
n += 1;
let failure = null;
try {
await withTimeout(t.fn(env), t.name);
} catch (e) {
failure = e.message;
}
// Any uncaught page error, console.error or unstubbed request
// fails the test that provoked it, whether or not its assertions
// passed. This is the mechanism that caught #150.
const newErrors = session.errors.take();
if (!failure && newErrors.length > 0) {
failure = "uncaught browser errors during this test";
}
if (failure) {
failed += 1;
console.log("not ok " + n + " - " + t.name);
console.log(" " + failure);
for (const line of newErrors) {
console.log(" " + line);
}
} else {
console.log("ok " + n + " - " + t.name);
}
}
// Keep watching after the last test returns, before tearing the
// browser down. A request a test fires without awaiting is still in
// flight when its function resolves; measured here it reaches the
// route handler about 10ms later, but closing the context does not
// wait for it — with no window at all the request dies unobserved
// and the run goes green, which is exactly how escaping traffic
// stays invisible.
//
// A fixed bounded window rather than a quiescence poll on purpose:
// the collector being quiet is not evidence, because a request that
// has not been dispatched yet has recorded nothing to be quiet
// about. Playwright offers no "is anything in flight" question to
// ask either — the route handler is the only observation point — so
// a grace period is the mechanism available, and this one is ~150x
// the measured latency for 1.5s on a ~25s suite.
await new Promise((resolve) => setTimeout(resolve, TRAILING_WATCH_MS));
await session.close();
// The tail. These cannot be blamed on any single test, so they are
// reported against the suite rather than guessed at — but they are
// reported, and they fail the run.
const trailing = session.errors.take();
console.log(
"# " + (tests.length - failed) + "/" + tests.length + " tests passed",
);
if (trailing.length > 0) {
console.log(
"# " +
trailing.length +
" browser error(s) recorded after the last test finished, " +
"not attributable to any single test:",
);
for (const line of trailing) {
console.log("# " + line);
}
}
if (failed > 0 || trailing.length > 0) {
console.log("# FAILED");
process.exitCode = 1;
}
}
main().catch((e) => {
console.error("e2e: " + (e && e.stack ? e.stack : e));
process.exitCode = 1;
});

1002
tests/transactions.test.js Normal file

File diff suppressed because it is too large Load Diff

94
tests/wallet.test.js Normal file
View File

@@ -0,0 +1,94 @@
// Tests for the DEBUG build flag as it gates mnemonic generation.
//
// The modules read the __BUILD_DEBUG__ global that esbuild replaces at bundle
// time. Under jest the global is absent, which is exactly the release-build
// case; the debug-build case is exercised by defining the global and
// re-requiring the modules with a fresh registry.
const WORDS_IN_12_WORD_PHRASE = 12;
function loadWallet() {
const constants = require("../src/shared/constants");
const wallet = require("../src/shared/wallet");
const log = require("../src/shared/log");
return { constants, wallet, log };
}
describe("generateMnemonic in a release build", () => {
beforeEach(() => {
jest.resetModules();
delete globalThis.__BUILD_DEBUG__;
});
test("DEBUG defaults to false when the build define is absent", () => {
const { constants } = loadWallet();
expect(constants.DEBUG).toBe(false);
});
test("returns fresh, valid 12-word phrases that are not the test phrase", () => {
const { constants, wallet } = loadWallet();
const first = wallet.generateMnemonic();
const second = wallet.generateMnemonic();
expect(first).not.toBe(second);
for (const phrase of [first, second]) {
expect(wallet.isValidMnemonic(phrase)).toBe(true);
expect(phrase.split(" ")).toHaveLength(WORDS_IN_12_WORD_PHRASE);
expect(phrase).not.toBe(constants.DEBUG_MNEMONIC);
}
});
test("derives a usable HD wallet from the generated phrase", () => {
const { wallet } = loadWallet();
const { xpub, firstAddress } = wallet.hdWalletFromMnemonic(
wallet.generateMnemonic(),
);
expect(xpub.startsWith("xpub")).toBe(true);
expect(firstAddress).toMatch(/^0x[0-9a-fA-F]{40}$/);
});
test("the runtime debug toggle cannot re-enable the test phrase", () => {
const { constants, wallet, log } = loadWallet();
// What the settings easter-egg toggle does at runtime.
log.setRuntimeDebug(true);
expect(log.isDebug()).toBe(true);
const phrase = wallet.generateMnemonic();
expect(phrase).not.toBe(constants.DEBUG_MNEMONIC);
expect(wallet.isValidMnemonic(phrase)).toBe(true);
expect(phrase).not.toBe(wallet.generateMnemonic());
log.setRuntimeDebug(false);
});
});
describe("generateMnemonic in a debug build", () => {
beforeEach(() => {
jest.resetModules();
globalThis.__BUILD_DEBUG__ = true;
});
afterEach(() => {
delete globalThis.__BUILD_DEBUG__;
});
test("DEBUG is true and the test phrase is returned", () => {
const { constants, wallet } = loadWallet();
expect(constants.DEBUG).toBe(true);
expect(wallet.generateMnemonic()).toBe(constants.DEBUG_MNEMONIC);
});
test("the test phrase is itself a valid 12-word BIP-39 phrase", () => {
const { constants, wallet } = loadWallet();
expect(wallet.isValidMnemonic(constants.DEBUG_MNEMONIC)).toBe(true);
expect(constants.DEBUG_MNEMONIC.split(" ")).toHaveLength(
WORDS_IN_12_WORD_PHRASE,
);
});
});

View File

@@ -2547,6 +2547,11 @@ pkg-dir@^4.2.0:
dependencies: dependencies:
find-up "^4.0.0" find-up "^4.0.0"
playwright-core@1.56.0:
version "1.56.0"
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.56.0.tgz#14b40ea436551b0bcefe19c5bfb8d1804c83739c"
integrity sha512-1SXl7pMfemAMSDn5rkPeZljxOCYAmQnYLBTExuh6E8USHXGSX3dx6lYZN/xPpTz1vimXmPA9CDnILvmJaB8aSQ==
pngjs@^5.0.0: pngjs@^5.0.0:
version "5.0.0" version "5.0.0"
resolved "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz" resolved "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz"