Compare commits

..

26 Commits

Author SHA1 Message Date
93a16bfca8 fix: wipe the exported private key from the DOM on leaving the screen (closes #221)
All checks were successful
check / check (push) Successful in 35s
The export screen registered no view-leave cleanup, so leaving it by any
route other than its own Back button — the settings gear, for instance —
left the decrypted private key in #export-privkey-value inside the hidden
view for the rest of the popup's life. The reveal path had the same
post-await hole the recovery phrase screen had: the write landed after
the wipe, with nothing scheduled to wipe it again.

The screen moves out of addressDetail.js into its own module shaped like
showPhrase.js: onViewLeave() cleanup that wipes the value node, the
password input and the closure state, and a revealGeneration liveness
guard captured before the decrypt. The guard sits in front of the key
derivation, so a decrypt that resolves after the screen was left does not
even derive the key.

The audit for the same bug class covered every other screen holding
secret material in the DOM. AddWallet (a generated or pasted recovery
phrase, an imported private key or extended private key, and the
password) and the password inputs on ConfirmTx, DeleteWallet, ApproveTx
and ApproveSign were all cleared on entry only, so each survived in its
hidden view after the screen navigated on. All five now register the same
cleanup. None of them writes a secret after an await, so none needs a
generation guard.

The load-bearing test leaves the screen mid-decrypt and asserts the key
never lands in the DOM, and that it still does not land once the user has
returned to the screen — which the generation counter catches and a
current-view check alone would not.
2026-08-12 08:26:37 +00:00
ba35282092 docs: describe the bundled token list by its criterion, not a drifting count (closes #239)
All checks were successful
check / check (push) Successful in 29s
2026-08-12 10:20:40 +02:00
158278d251 fix: count the network fee in the confirm-screen balance check (closes #154)
All checks were successful
check / check (push) Successful in 25s
2026-08-11 15:41:37 +02:00
6f6bc2e7b5 fix: drive background refresh and phishing update from alarms (closes #158)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 15:38:28 +02:00
74c137dadf fix: add a Settings toggle for known-symbol spoof verification (closes #176)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 15:38:05 +02:00
edea22f7ed fix: move the UTC Timestamps checkbox into the Display well (closes #212)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 15:34:16 +02:00
b155c0fcd6 fix: enforce the base58 checksum and reject non-master extended keys (closes #210)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 15:31:50 +02:00
fb9e8f5542 fix: NUL-delimit verify-build's dist walk so no path escapes the check (closes #223)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 15:26:43 +02:00
3e5d6323ce feat: password-gated recovery phrase display for HD wallets (closes #161)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 15:25:17 +02:00
12acf4dc8c fix: honour a dust threshold of 0 and compare addresses case-insensitively (closes #179)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 15:16:48 +02:00
f455b0ae7f test: known-answer coverage for HD derivation and the vault (closes #159)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 15:06:35 +02:00
86cdea5e4e chore: repo policy compliance sweep — test rerun, frozen lockfile, documented targets (closes #166)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 14:57:51 +02:00
f271bcd7b4 fix: one transaction history row per value movement (closes #177)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 14:56:30 +02:00
93e3f6e4e2 fix: correct verify-build diagnostics and close two robustness gaps (closes #180)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 14:55:06 +02:00
9b957ffd69 fix: derive hasWallet from the wallet list on load (closes #195)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 14:51:22 +02:00
cf5f582be9 docs: correct three README claims contradicted by the code (closes #213)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 14:41:16 +02:00
b9bc226ae1 docs: rebuild the README Screen Map from the code (closes #164)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 14:31:45 +02:00
19cb1ca1b0 docs: correct docs/README.md external services and remove competitor names (closes #163)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 14:25:57 +02:00
b882cede9f fix: repair wallet state on delete (closes #156)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 14:23:06 +02:00
d93eda31a0 docs: rewrite TODO.md workflow for the branch-per-issue model on next (closes #191)
All checks were successful
check / check (push) Successful in 22s
2026-08-11 14:15:05 +02:00
e9fa8bec47 build: assert DEBUG is off in every emitted bundle as a post-build check (closes #170)
All checks were successful
check / check (push) Successful in 18s
build.js records which emitted bundles contain src/shared/constants.js, and
constants.js carries a marker constant-folded from DEBUG itself. script/verify-build
cross-checks the two and fails on every way of not knowing, so deleting the
__BUILD_DEBUG__ define now breaks the build instead of shipping a live debug branch.
2026-08-10 16:15:22 +02:00
ad9162d057 security: decrypt and sign dApp approvals in the popup (closes #157)
All checks were successful
check / check (push) Successful in 24s
The password no longer crosses the extension messaging boundary: the popup
decrypts and signs, and sends only the raw signed transaction or the signature.
The background re-derives the signer from the artifact and checks it against
the approval it holds before broadcasting, so it is not a blind relay.
2026-08-10 15:59:30 +02: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
62 changed files with 9724 additions and 3113 deletions

View File

@@ -1,4 +1,6 @@
.git # .git is deliberately NOT excluded: build.js shells out to `git rev-parse` for
# build-info stamping and the Dockerfile runs `make build`, so excluding it
# would make every built extension report commitHash "unknown".
node_modules 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
@@ -11,11 +11,15 @@ setup:
@script/setup @script/setup
install: install:
@yarn install @yarn install --frozen-lockfile
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/

783
README.md

File diff suppressed because it is too large Load Diff

186
TODO.md
View File

@@ -1,33 +1,164 @@
# Workflow # Workflow
- branch (from `main`) - `git pull` `next` and cut a branch from it — one branch per issue, named
- do the work in Next Step `issue-<N>-<slug>`. Never branch from `main`.
- move Next Step to the top of Completed Steps - Do the work as one commit whose title ends with ` (closes #N)`, with the
- move the top item of Future Steps into Next Step `TODO.md` update in that same commit.
- commit (`TODO.md` changes in the same commit as the work) - Move Next Step to the top of Completed Steps; move the top item of Future
- merge to `main` if the branch is not protected, otherwise open a PR Steps into Next Step.
- push - Run `make fmt`, then `make check`. A feature branch may be red; `next` and
`main` may not.
- Rebase onto current `next` immediately before pushing — other branches land on
`next` continuously — and re-run `make check` after resolving, because a clean
textual merge can still break the build.
- Push the branch and open one PR per issue with base `next`. Never base `main`.
- An independent reviewer who did not write the change gates the merge. On a
passed review the PR is squash-merged into `next`.
- `next` is the branch for the next milestone. It is kept green and mergeable to
`main` at any moment, without notice.
- `main` receives exactly one PR per milestone, from `next`. Releases are tagged
from `main`.
# Status # 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. The
feat/issue-144-settings-about (another agent working as of 2026-07-06). Full milestone is in flight on `next`; its `next` -> `main` PR is
policy file set present; make check on main not verified. [#190](https://git.eeqj.de/sneak/AutistMask/pulls/190). `make check` verified
green on `next` at `e9fa8be` on 2026-08-10, and `make build` produces
`dist/chrome/` and `dist/firefox/` with every bundle verified to have `DEBUG`
compiled off.
The backlog lives on the
[Gitea tracker](https://git.eeqj.de/sneak/AutistMask/issues), which is
authoritative; this file does not duplicate it. Full policy file set present. 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 [#152](https://git.eeqj.de/sneak/AutistMask/issues/152): add ESLint to
app name and repo link, release date, version click easter egg, git info derived `script/lint`. `make check` is `prettier --check` only today and cannot catch
inside Docker), resolve the untracked scripts/ directory (commit or gitignore), undefined identifiers, which is how
get review, merge to main. [#150](https://git.eeqj.de/sneak/AutistMask/issues/150) and
[#151](https://git.eeqj.de/sneak/AutistMask/issues/151) shipped.
# Completed Steps # Completed Steps
- 2026-08-12: The private key export screen now wipes the key from the page
whenever it is left by any route, and a decrypt still in flight when the
screen is left is discarded instead of written; the same `onViewLeave()`
cleanup was extended to every other screen holding secret material in the DOM
(AddWallet, ConfirmTx, DeleteWallet, ApproveTx, ApproveSign)
([#221](https://git.eeqj.de/sneak/AutistMask/issues/221)).
- 2026-08-12: Bundled token list documentation no longer states a count. The
four "top 250" claims in `README.md` and the "roughly 500" claim in
`docs/README.md` are replaced with a description of how the list is actually
selected — a point-in-time CoinGecko snapshot of the highest-market-cap
Ethereum mainnet ERC-20s — with `TOKENS` in `src/shared/tokenList.js` named as
the authoritative set
([#239](https://git.eeqj.de/sneak/AutistMask/issues/239)).
- 2026-08-11: Known-symbol spoof verification became a Settings toggle
(`hideSpoofedSymbols`), on by default, governing the transaction-history
filter and the fraud-contract learning it feeds
([#176](https://git.eeqj.de/sneak/AutistMask/issues/176)).
- 2026-08-11: `script/verify-build` now walks `dist/` NUL-delimited and asserts
`dist/` is a real directory, so a path with a trailing space or a newline can
no longer carry a debug marker past the unlisted-bundle check
([#223](https://git.eeqj.de/sneak/AutistMask/issues/223)).
- 2026-08-11: UTC Timestamps checkbox moved from the Token Spam Protection well
into Display, next to the theme selector
([#212](https://git.eeqj.de/sneak/AutistMask/issues/212)).
- 2026-08-11: Network fee counted in the confirmation-screen balance check for
both ETH and ERC-20 sends, reserving what the node actually charges a type-2
transaction, with the arithmetic in a pure, unit-tested
`src/shared/txValidation.js`
([#154](https://git.eeqj.de/sneak/AutistMask/issues/154)).
- 2026-08-11: A dust threshold of `0` now means "hide nothing" instead of
falling back to the 100,000 gwei default, and every address comparison in
`src/shared/transactions.js` goes through one case-normalising helper so a
checksummed genuine contract is no longer read as a spoof
([#179](https://git.eeqj.de/sneak/AutistMask/issues/179)).
- 2026-08-11: Password-gated recovery phrase display for HD wallets, reached
from the wallet row in Settings, wiped on leaving the screen and excluded from
the views the popup can reopen onto
([#161](https://git.eeqj.de/sneak/AutistMask/issues/161)).
- 2026-08-11: Extended-key import hardened — the base58 checksum is now enforced
on every xprv and xpub, and a non-master key is refused with an explanation
instead of being derived beneath
([#210](https://git.eeqj.de/sneak/AutistMask/issues/210)).
- 2026-08-11: the balance refresh and the 24-hour phishing list refresh moved
from `setInterval` to the extension alarms API, with the phishing delta and
its fetch timestamps persisted to extension storage, so neither job dies with
the MV3 service worker. Each job's freshness guard was decoupled from its
alarm period at the same time — timed to the period, a guard vetoes its own
scheduled tick and halves the real refresh rate
([#158](https://git.eeqj.de/sneak/AutistMask/issues/158)).
- 2026-08-11: Policy compliance sweep — conditional verbose test rerun, local
Tailwind binary instead of `npx`, `--frozen-lockfile` on `make install`, and
the Makefile-only targets documented in the README
([#166](https://git.eeqj.de/sneak/AutistMask/issues/166)).
- 2026-08-11: `script/verify-build` diagnostics corrected: the both-markers
message now states what is and is not proven, an unreadable bundle is
diagnosed as an I/O fault rather than as changed output, the `*.js` assumption
lives only in `build.js`, and the unlisted-bundle scan hard-fails when it
cannot enumerate `dist/`
([#180](https://git.eeqj.de/sneak/AutistMask/issues/180)).
- 2026-08-11: Known-answer test coverage for the crypto core — BIP-39/BIP-32
derivation in `wallet.js` and the Argon2id vault in `vault.js`
([#159](https://git.eeqj.de/sneak/AutistMask/issues/159)).
- 2026-08-11: Three `README.md` claims corrected against the code — blocklist
attribution, token-display rule, navigation model
([#213](https://git.eeqj.de/sneak/AutistMask/issues/213)).
- 2026-08-11: README Screen Map rebuilt from the code — every screen, element
and transition re-verified against `src/popup/`
([#164](https://git.eeqj.de/sneak/AutistMask/issues/164)).
- 2026-08-11: `docs/README.md` rewritten against the code: no competitor names,
all five network destinations documented, password/Settings/Add Wallet
sections corrected ([#163](https://git.eeqj.de/sneak/AutistMask/issues/163)).
- 2026-08-11: `loadState()` now derives `hasWallet` from the wallet list instead
of trusting the persisted flag, so a profile already saved inconsistent no
longer stays broken on every load
([#195](https://git.eeqj.de/sneak/AutistMask/issues/195)).
- 2026-08-11: Wallet deletion repairs its own state — `hasWallet` follows the
remaining wallets, the selection only moves when it was deleted, and the
active-address change is broadcast to connected sites
([#156](https://git.eeqj.de/sneak/AutistMask/issues/156)).
- 2026-08-11: One row per on-chain value movement in transaction history: the
merge moved into the pure `mergeTransactions` and the zero-ETH native side of
a plain ERC-20 transfer absorbed into its token row
([#177](https://git.eeqj.de/sneak/AutistMask/issues/177)).
- 2026-08-11: `TODO.md` Workflow rewritten to the branch-and-PR-per-issue model
on `next`, with Status and Next Step refreshed
([#191](https://git.eeqj.de/sneak/AutistMask/issues/191)).
- 2026-08-09: `DEBUG` became a build-time constant defaulting to off, injected
as the `__BUILD_DEBUG__` esbuild define and turned on with
`AUTISTMASK_DEBUG=1`, so a plain `make build` no longer hands every newly
created wallet the publicly committed test recovery phrase
([#149](https://git.eeqj.de/sneak/AutistMask/issues/149)).
- 2026-08-09: dApp approval signing moved into the popup — the password no
longer crosses the extension messaging boundary; the background broadcasts and
resolves approvals only, and verifies the signed artifact against the approval
it holds (#157).
- 2026-08-09: Post-build assertion that every emitted bundle containing
`constants.js` has `DEBUG` compiled off, via `script/verify-build` on the
`make build` path (#170).
- 2026-08-09: Containerized Chrome end-to-end harness (`make test-e2e` /
`script/test-e2e`) driving the real popup with all network intercepted, plus
the two used-but-not-imported crashes it caught: AddToken unreachable (#150)
and TransactionDetail broken for every ERC-20 transfer (#151). Harness
demonstrated failing before the fixes and passing after (#181). Interception
covers the MV3 background service worker, not just the popup page, and a
launch-time canary aborts the suite if worker traffic starts escaping.
- 2026-08-09: Reviewed the repo end to end and filed the 1.0.0 backlog
(#149-#168).
- 2026-08-09: Test coverage for the address-poisoning defense in
`src/shared/transactions.js` (#160)
- 2026-07-26: About well in settings with build info, repo link and the version
click easter egg (#145); proper view navigation stack (#146).
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile - 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 +176,15 @@ get review, merge to main.
# Future Steps # Future Steps
- Verify main passes make check after the feature branch merges (not verified Only work that has no issue of its own belongs here; everything else is on the
2026-07-06 because an agent was active in the tree); fix anything red. main tracker.
must always be green.
- Prune stale branches: dozens of merged local and remote feature branches
remain (fix/_, feature/_, tx-\*); delete merged ones locally and on origin.
- Continue the issue backlog toward a feature-complete wallet, then cut further
tags as milestones land.
- 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. Individual filed issues are parts of
it, but the review is broader than any of them.
- Decide whether docker-in-docker makes `make test-e2e` runnable in the Gitea
workflow. Extending the suite itself is tracked as
[#183](https://git.eeqj.de/sneak/AutistMask/issues/183) and
[#184](https://git.eeqj.de/sneak/AutistMask/issues/184).
- Cut 1.0.0 once the milestone is empty, then continue tagging as milestones
land.

200
build.js
View File

@@ -3,75 +3,183 @@ 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.
//
// The ".js" filter below is the only place that assumption lives:
// script/verify-build searches every file and symlink under dist/ for a
// marker, without filtering by extension, and hard-fails if it cannot walk the
// whole tree, so a bundle emitted under some other extension fails there as
// unlisted rather than escaping both checks at once.
function outputsContainingAuditedModule(metafile) {
return Object.entries(metafile.outputs)
.filter(([outFile, info]) => {
if (!outFile.endsWith(".js")) return false;
return Object.keys(info.inputs).some(
(input) => repoRelative(input) === AUDITED_MODULE,
);
})
.map(([outFile]) => repoRelative(outFile));
}
// DEBUG is a build-time flag, off unless explicitly requested. It is the only
// thing that makes the hardcoded test mnemonic reachable, so the opt-in must be
// exact: anything other than the literal "1" (unset, empty, "true", a typo)
// 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 });
// The locally installed binary, not `npx` — npx silently fetches from the
// registry when the binary is absent, which is an unpinned network fetch
// in the middle of a build.
const tailwindBin = path.join(
__dirname,
"node_modules",
".bin",
"tailwindcss",
);
execSync( execSync(
`npx @tailwindcss/cli -i ${tailwindInput} -o ${tailwindOutput} --minify`, `"${tailwindBin}" -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 +204,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

@@ -6,10 +6,10 @@ and ERC-20 tokens, and connects to web3 sites. Nothing else.
## Why AutistMask Exists ## Why AutistMask Exists
MetaMask has become bloated with swap UIs, portfolio dashboards, analytics, The most popular browser-based EVM wallet has become bloated with swap UIs,
tracking, and advertisements. It is no longer a simple wallet. Most alternatives portfolio dashboards, analytics, tracking, and advertisements. It is no longer a
(Rabby, Rainbow, etc.) only support Chromium browsers, leaving Firefox users simple wallet. The common alternatives only support Chromium browsers, leaving
without a usable option. Firefox users without a usable option.
AutistMask exists because a wallet should be a wallet. You should be able to see AutistMask exists because a wallet should be a wallet. You should be able to see
your balances, send tokens, receive tokens, and connect to sites. That is all a your balances, send tokens, receive tokens, and connect to sites. That is all a
@@ -27,9 +27,10 @@ analytics, use a portfolio tracker. The wallet is not the place for any of that.
- **Encrypt your recovery phrase and private keys at rest.** Your secrets are - **Encrypt your recovery phrase and private keys at rest.** Your secrets are
encrypted on disk using Argon2id key derivation and XSalsa20-Poly1305 encrypted on disk using Argon2id key derivation and XSalsa20-Poly1305
authenticated encryption (via libsodium). Your password is required only when authenticated encryption (via libsodium). Your password is required whenever a
signing a transaction. Viewing balances and addresses never requires a secret has to be decrypted: signing a transaction, signing a message or typed
password. data, exporting a private key, and deleting a wallet. Viewing balances and
addresses never requires a password.
- **Let you choose your own RPC endpoint.** The default is a public Ethereum - **Let you choose your own RPC endpoint.** The default is a public Ethereum
RPC, but you can point it at your own node or any provider you trust. No RPC, but you can point it at your own node or any provider you trust. No
@@ -56,23 +57,25 @@ analytics, use a portfolio tracker. The wallet is not the place for any of that.
- **No NFT galleries or portfolio views.** This is a wallet, not a dashboard. - **No NFT galleries or portfolio views.** This is a wallet, not a dashboard.
- **No token auto-discovery.** AutistMask does not scan the blockchain for - **No third-party token list APIs.** Token balances come from the same block
tokens you might hold. You add tokens manually by contract address. This explorer you configure for transaction history, and the extension ships its
prevents scam tokens from appearing in your wallet uninvited. own hardcoded list of top ERC-20 contract addresses for symbol-spoofing
detection. Any token you want tracked across all your addresses, you add
yourself by contract address.
- **No phishing blocklists from third parties.** AutistMask does not phone home - **No backend servers operated by the developer.** Nothing is sent to any
to check URLs against a remote blocklist. It does maintain a local list of server run by AutistMask. Every network destination is listed below.
known scam addresses, but this is shipped with the extension, not fetched from
a server.
## How It Works ## How It Works
AutistMask is a browser extension that runs entirely in your browser. It does AutistMask is a browser extension that runs entirely in your browser. It does
not have a backend server. It communicates with three external services: not have a backend server. It communicates with five external destinations:
three you configure yourself, and two fixed ones used for scam detection.
### External Services ### External Services
**Ethereum JSON-RPC endpoint** (default: `ethereum-rpc.publicnode.com`) **Ethereum JSON-RPC endpoint** (default: `ethereum-rpc.publicnode.com`;
`ethereum-sepolia-rpc.publicnode.com` on Sepolia)
This is how AutistMask talks to the Ethereum network. Every wallet needs an This is how AutistMask talks to the Ethereum network. Every wallet needs an
Ethereum node to check balances, estimate gas, broadcast transactions, and Ethereum node to check balances, estimate gas, broadcast transactions, and
@@ -80,27 +83,77 @@ verify confirmations. The default is a free public RPC endpoint. You can change
this in Settings to any Ethereum JSON-RPC endpoint, including your own local this in Settings to any Ethereum JSON-RPC endpoint, including your own local
node. node.
What gets sent: standard Ethereum JSON-RPC requests (balance queries, When it is contacted: on every balance refresh (every 10 seconds while the popup
transaction broadcasts, gas estimates, ENS lookups). Your addresses are is open, every 60 seconds in the background), when you type an ENS name into the
necessarily visible to the RPC provider when querying balances. Send screen, when a send is prepared and broadcast, while a pending transaction
is polled for its receipt, and for the reverse ENS lookups used to label
addresses (cached for 12 hours).
**Blockscout API** (default: `eth.blockscout.com/api/v2`) What gets sent: standard Ethereum JSON-RPC requests (balance queries,
transaction broadcasts, gas estimates, ENS lookups, contract-code checks). Your
addresses are necessarily visible to the RPC provider when querying balances.
**Blockscout API** (default: `eth.blockscout.com/api/v2`;
`eth-sepolia.blockscout.com/api/v2` on Sepolia)
Used to fetch token balances and transaction history. Blockscout is an Used to fetch token balances and transaction history. Blockscout is an
open-source blockchain explorer. AutistMask queries it for your ERC-20 token open-source blockchain explorer. AutistMask queries it for your ERC-20 token
balances and recent transactions. You can change this in Settings to a balances (including the holder counts used for spam filtering) and your recent
transactions and token transfers. You can change this in Settings to a
self-hosted Blockscout instance. self-hosted Blockscout instance.
When it is contacted: on every balance refresh, and whenever a screen showing
transaction history is opened.
What gets sent: your Ethereum addresses (to look up balances and transactions). What gets sent: your Ethereum addresses (to look up balances and transactions).
**CoinDesk CADLI price API** (`data-api.coindesk.com`) **CoinDesk CADLI price API** (`data-api.coindesk.com`)
Used to fetch current USD prices for ETH and ERC-20 tokens. Prices are cached Used to fetch current USD prices for ETH and the top 25 tokens. Prices are
for 5 minutes. No API key is required. No user data is sent -- only a list of cached for 5 minutes. No API key is required. This endpoint is not
token symbols (e.g. "ETH", "USDC") to get their prices. user-configurable, and it is not contacted at all while you are on a testnet,
where no USD values are shown.
What gets sent: token symbol names. No addresses, no balances, no identifying When it is contacted: while the popup is open, at most once every 5 minutes.
information.
What gets sent: token symbol names (e.g. "ETH", "USDC"). No addresses, no
balances, no identifying information. As with any request, CoinDesk sees your IP
address.
**Phishing domain blocklist** (`raw.githubusercontent.com`)
A community-maintained list of phishing domains, used to warn you when a site
that asks to connect, or to have a transaction or signature approved, is a known
scam. A copy is bundled into the extension at build time, so the protection
works before any network request happens. At runtime the extension fetches the
live list to pick up newly added domains, keeping only the entries not already
in the bundled copy (persisted locally if under 256 KiB). This endpoint is not
user-configurable.
When it is contacted: when the background script starts, if the last fetch was
more than 24 hours ago, and every 24 hours after that. The time of the last
fetch is remembered across browser and background restarts, so restarting does
not cause a re-download. If a fetch fails, or the list is too large to keep, the
extension waits an hour before trying again outside that 24-hour schedule rather
than retrying on every restart. It is a plain download of a public file —
nothing about you is sent, but the host sees your IP address. If the fetch
fails, the bundled copy is still used.
**Etherscan address labels** (`etherscan.io`; `sepolia.etherscan.io` on Sepolia)
When you review a send, AutistMask fetches the recipient's public Etherscan
address page and looks for a "Fake_Phishing"/"Phish/Hack" label or a scam
warning, and shows a red warning if it finds one. This is a plain page fetch
with no API key, made by your browser. It is best-effort: if it fails, it is
silently ignored. This endpoint is not user-configurable.
When it is contacted: each time you reach the send confirmation screen.
What gets sent: the recipient address you are about to send to, and your IP
address. Your own addresses are not sent.
Etherscan links shown elsewhere in the UI (on addresses, transactions, and token
contracts) are ordinary links. They contact nothing until you click them.
### What Stays Local ### What Stays Local
@@ -123,8 +176,11 @@ word recovery phrase can restore your wallet on any device without your
password. The password only protects the copy stored in this browser. If you password. The password only protects the copy stored in this browser. If you
lose your recovery phrase, your password cannot help you recover it. lose your recovery phrase, your password cannot help you recover it.
Your password is only requested when you send a transaction. Viewing balances, Your password is requested whenever an encrypted secret must be decrypted: when
receiving funds, and browsing transaction history never require your password. you send a transaction, when a site asks you to sign a message or typed data,
when you export an address's private key, and when you delete a wallet. Viewing
balances, receiving funds, and browsing transaction history never require your
password.
## Installation ## Installation
@@ -147,34 +203,46 @@ receiving funds, and browsing transaction history never require your password.
### Creating a New Wallet ### Creating a New Wallet
1. Click the AutistMask icon in your browser toolbar. 1. Click the AutistMask icon in your browser toolbar.
2. Click "Add wallet". 2. Click "Add wallet" (on first use), or open Settings and click "+ Add wallet".
3. Click the die button to generate a random 12-word recovery phrase. 3. On the "From Phrase" tab, click the die button to generate a random 12-word
recovery phrase.
4. **Write down the recovery phrase and store it safely.** Anyone with these 4. **Write down the recovery phrase and store it safely.** Anyone with these
words can take your funds. If you lose them, your wallet is gone. AutistMask words can take your funds. If you lose them, your wallet is gone. AutistMask
cannot recover them for you. cannot recover them for you.
5. Choose a password. This encrypts your recovery phrase on this device. 5. Choose a password and confirm it. This encrypts your recovery phrase on this
6. Click "Add". device.
6. Click "Import".
### Importing an Existing Wallet ### Importing an Existing Wallet
**From a recovery phrase:** Follow the same steps as creating a wallet, but The Add Wallet screen has three tabs:
paste your existing 12 or 24 word recovery phrase instead of generating a new
one. AutistMask uses the same derivation path as MetaMask (`m/44'/60'/0'/0`), so
your addresses will match.
**From a private key:** On the Add Wallet screen, click "Have a private key **From Phrase:** Paste your existing 12 or 24 word recovery phrase instead of
instead?" and paste your private key. This creates a single-address wallet. generating a new one. AutistMask uses the standard BIP-44 Ethereum derivation
path (`m/44'/60'/0'/0`), which is what other wallets use by default, so your
addresses will match and your phrase stays portable in both directions.
**From Key:** Paste a single private key. This creates a single-address wallet.
**From xprv:** Paste an extended private key. This imports the HD wallet and
scans for used addresses.
All three tabs ask for the same password fields, and the "Import" button
finishes the job.
### Adding More Addresses ### Adding More Addresses
HD wallets (created from a recovery phrase) can derive multiple addresses. On HD wallets (created from a recovery phrase or an xprv) can derive multiple
the home screen, click the "+" button next to a wallet name to add the next addresses. On the home screen, click the "+" button next to a wallet name to add
address. These are deterministic -- the same recovery phrase will always produce the next address. These are deterministic -- the same recovery phrase will
the same sequence of addresses. always produce the same sequence of addresses.
### Adding ERC-20 Tokens ### Adding ERC-20 Tokens
AutistMask does not auto-discover tokens. To track a token: Tokens you hold show up automatically only if they are in the extension's
bundled list of well-known tokens or have at least 1,000 holders; everything
else is treated as spam and hidden. To track a token explicitly (which also
shows it at zero balance), add it by contract address:
1. Go to an address detail view (click `[info]` on any address). 1. Go to an address detail view (click `[info]` on any address).
2. Click "+ Token". 2. Click "+ Token".
@@ -183,12 +251,13 @@ AutistMask does not auto-discover tokens. To track a token:
4. Click "Add". 4. Click "Add".
The token balance will appear on the address detail screen and on the home The token balance will appear on the address detail screen and on the home
screen. screen. Tokens can also be added from Settings, under "Tracked Tokens".
## Sending ## Sending
1. Click "Send" from the home screen or an address detail view. 1. Click "Send" from the home screen or an address detail view.
2. Select what to send (ETH or any tracked ERC-20 token). 2. Select what to send (ETH, or any ERC-20 token with a balance on this address
that survives the spam filters).
3. Enter the recipient address or ENS name (e.g. `vitalik.eth`). 3. Enter the recipient address or ENS name (e.g. `vitalik.eth`).
4. Enter the amount. 4. Enter the amount.
5. Click "Review" to see the confirmation screen. 5. Click "Review" to see the confirmation screen.
@@ -200,12 +269,18 @@ The confirmation screen shows:
- **From and To addresses** with identicons and Etherscan links - **From and To addresses** with identicons and Etherscan links
- **Amount** with USD estimate - **Amount** with USD estimate
- **Your current balance** with USD estimate - **Your current balance** with USD estimate
- **Estimated network fee** in ETH with USD estimate - **Network fee** — what the transfer is expected to cost, in ETH with a USD
estimate, and below it the larger amount reserved until it confirms. The
reserve is what the network requires up front and what the balance check gates
on; the refund of the difference is why the two differ
- **Warnings** if the recipient is a contract, a burn address, one of your own
addresses, on the bundled scam-address list, or labelled as a phisher on
Etherscan
After reviewing, click "Send" and enter your password. The transaction will be After reviewing, enter your password and click "Sign & Send". The transaction
broadcast to the network and you will see a waiting screen with a timer. Once will be broadcast to the network and you will see a waiting screen with a timer.
confirmed (or after 60 seconds), you will see either a success or error screen Once confirmed (or after 60 seconds), you will see either a success or error
with the transaction hash and an Etherscan link. screen with the transaction hash and an Etherscan link.
### Sending a Specific Token ### Sending a Specific Token
@@ -219,10 +294,10 @@ cannot accidentally switch to a different one.
1. Click "Receive" from the home screen or an address detail view. 1. Click "Receive" from the home screen or an address detail view.
2. Share the QR code or copy the address using the "Copy address" button. 2. Share the QR code or copy the address using the "Copy address" button.
When receiving ERC-20 tokens, make sure the sender is sending on the Ethereum When receiving ERC-20 tokens, make sure the sender is sending on the network you
network. AutistMask is an Ethereum mainnet wallet. Tokens sent on other networks are using. AutistMask supports Ethereum mainnet and the Sepolia testnet. Tokens
(Polygon, Arbitrum, BSC, etc.) to the same address will not appear and may be sent on other networks (Polygon, Arbitrum, BSC, etc.) to the same address will
permanently lost. not appear and may be permanently lost.
## Connecting to Web3 Sites ## Connecting to Web3 Sites
@@ -237,7 +312,12 @@ pages. When a site requests access to your wallet:
When a connected site requests a transaction, a separate approval popup appears When a connected site requests a transaction, a separate approval popup appears
showing the transaction details (from, to, value, data). You must enter your showing the transaction details (from, to, value, data). You must enter your
password and click "Confirm" to authorize it. password and click "Confirm" to authorize it. Message and typed-data signature
requests work the same way, with a "Sign" button, and also require your
password.
If the requesting site's domain is on the phishing blocklist, all three approval
screens show a red phishing warning before you decide.
You can manage site permissions in Settings. Allowed and denied sites can be You can manage site permissions in Settings. Allowed and denied sites can be
individually removed to reset their permissions. individually removed to reset their permissions.
@@ -247,15 +327,25 @@ individually removed to reset their permissions.
AutistMask includes several defenses against common Ethereum scams, all enabled AutistMask includes several defenses against common Ethereum scams, all enabled
by default: by default:
**Known token symbol verification.** AutistMask ships a list of ~250 legitimate **Known token symbol verification.** AutistMask ships a bundled list of
ERC-20 tokens with their contract addresses. If a transaction claims to involve high-market-cap ERC-20 tokens with their legitimate contract addresses — a
a known symbol (like "ETH" or "USDT") but comes from an unrecognized contract, point-in-time snapshot of the highest-market-cap Ethereum mainnet ERC-20s, fixed
it is identified as a spoof and hidden. at build time and updated only when a new release ships a newer snapshot. If a
transaction or balance claims to involve a known symbol (like "ETH" or "USDT")
but comes from an unrecognized contract, it is identified as a spoof and hidden.
In your transaction history this is the "Hide fake tokens impersonating a known
symbol" setting, which you can switch off; doing so also stops new entries being
added to the fraud contract blocklist below, since detecting a spoof is what
fills it. The send token list always applies the check. Your balances apply it
too, with one exception: a token claiming the symbol "ETH" is not filtered
there, so a fake "ETH" token can still show up in your balance list even though
it is hidden from your transaction history and from the send token list.
**Low-holder token filtering.** Tokens with fewer than 1,000 holders are hidden **Low-holder token filtering.** Tokens with fewer than 1,000 holders are hidden
from transaction history and the send token list. Legitimate tokens have from transaction history and the send token list, and are left out of your
substantial holder counts; scam tokens deployed for address poisoning typically balances unless they are on the bundled known-token list or you added them
have zero. yourself. Legitimate tokens have substantial holder counts; scam tokens deployed
for address poisoning typically have zero.
**Fraud contract blocklist.** When AutistMask detects a fraudulent transfer, it **Fraud contract blocklist.** When AutistMask detects a fraudulent transfer, it
adds the contract address to a local blocklist. Future transactions from that adds the contract address to a local blocklist. Future transactions from that
@@ -266,15 +356,29 @@ ETH by default) are hidden. Scammers send dust from look-alike addresses to
plant them in your transaction history. The threshold is configurable in plant them in your transaction history. The threshold is configurable in
Settings. Settings.
All of these filters can be individually disabled in Settings if you prefer to **Scam address list.** A list of known fraud, drainer, and phishing addresses is
shipped with the extension. Sending to one of them raises a warning on the
confirmation screen. It contains only addresses involved in fraud -- it is not a
sanctions list.
**Phishing domain warnings.** Sites asking to connect or to have something
approved are checked against the phishing domain blocklist described under
External Services, and flagged with a red banner if they match.
The first four filters can be individually disabled in Settings if you prefer to
see everything unfiltered. see everything unfiltered.
## Settings ## Settings
Click the gear icon on the home screen to access settings: Click the gear icon on the home screen to access settings:
- **Wallets**: Add a new wallet. - **Wallets**: Your wallets, and "+ Add wallet".
- **Display**: Toggle whether tracked tokens with zero balance are shown. - **Tracked Tokens**: The ERC-20 tokens tracked across all addresses, and "+ Add
token".
- **Display**: Toggle whether tracked tokens with zero balance are shown, switch
timestamps to UTC, and choose the theme (System, Light, or Dark).
- **Network**: Switch between Ethereum Mainnet and Sepolia Testnet. Switching
resets the RPC and Blockscout endpoints to that network's defaults.
- **Ethereum RPC**: Change the Ethereum node endpoint. Default is a public RPC. - **Ethereum RPC**: Change the Ethereum node endpoint. Default is a public RPC.
You can use your own node for maximum privacy. You can use your own node for maximum privacy.
- **Blockscout API**: Change the Blockscout instance used for token balances and - **Blockscout API**: Change the Blockscout instance used for token balances and
@@ -282,15 +386,17 @@ Click the gear icon on the home screen to access settings:
- **Token Spam Protection**: Toggle individual scam filters and set the dust - **Token Spam Protection**: Toggle individual scam filters and set the dust
transaction threshold. transaction threshold.
- **Allowed Sites / Denied Sites**: View and manage web3 site permissions. - **Allowed Sites / Denied Sites**: View and manage web3 site permissions.
- **About**: License, author, version, release date, and a link to the commit
this build came from.
## Frequently Asked Questions ## Frequently Asked Questions
**Is AutistMask compatible with MetaMask?** **Can I use AutistMask alongside another wallet?**
Yes. AutistMask uses the same derivation path (`m/44'/60'/0'/0`) as MetaMask. If Yes. AutistMask uses the standard `m/44'/60'/0'/0` derivation path, so importing
you import the same recovery phrase, you will get the same addresses. You can the same recovery phrase gives you the same addresses as any other wallet using
use both wallets side by side, though only one can be the active that path. Two wallet extensions can be installed side by side, though only one
`window.ethereum` provider at a time. can be the active `window.ethereum` provider at a time.
**Can I use AutistMask with a hardware wallet?** **Can I use AutistMask with a hardware wallet?**
@@ -298,8 +404,9 @@ Not yet. Hardware wallet support may be added in the future.
**Does AutistMask support networks other than Ethereum mainnet?** **Does AutistMask support networks other than Ethereum mainnet?**
Not currently. AutistMask is Ethereum mainnet only. Multi-chain support may be Ethereum mainnet and the Sepolia testnet, selectable in Settings. No other
added in the future. networks are supported today. On Sepolia, USD values are not shown, because
testnet tokens have no market value.
**Where is my data stored?** **Where is my data stored?**
@@ -312,7 +419,7 @@ to any server operated by AutistMask.
Your data is deleted. Make sure you have your recovery phrase backed up before Your data is deleted. Make sure you have your recovery phrase backed up before
uninstalling. With your recovery phrase, you can restore your wallet in uninstalling. With your recovery phrase, you can restore your wallet in
AutistMask or any other compatible wallet (MetaMask, etc.) at any time. AutistMask or any other wallet that uses the standard derivation path.
**What happens if a transaction times out?** **What happens if a transaction times out?**

View File

@@ -3,7 +3,7 @@
"name": "AutistMask", "name": "AutistMask",
"version": "0.1.0", "version": "0.1.0",
"description": "Minimal Ethereum wallet for Chrome", "description": "Minimal Ethereum wallet for Chrome",
"permissions": ["storage", "activeTab"], "permissions": ["storage", "activeTab", "alarms"],
"host_permissions": ["<all_urls>"], "host_permissions": ["<all_urls>"],
"action": { "action": {
"default_popup": "src/popup/index.html" "default_popup": "src/popup/index.html"

View File

@@ -3,7 +3,7 @@
"name": "AutistMask", "name": "AutistMask",
"version": "0.1.0", "version": "0.1.0",
"description": "Minimal Ethereum wallet for Firefox", "description": "Minimal Ethereum wallet for Firefox",
"permissions": ["storage", "activeTab", "<all_urls>"], "permissions": ["storage", "activeTab", "alarms", "<all_urls>"],
"browser_action": { "browser_action": {
"default_popup": "src/popup/index.html" "default_popup": "src/popup/index.html"
}, },

View File

@@ -7,6 +7,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"test": "jest --forceExit", "test": "jest --forceExit",
"test:verbose": "jest --forceExit --verbose",
"build": "node build.js", "build": "node build.js",
"lint": "prettier --check .", "lint": "prettier --check .",
"fmt": "prettier --write .", "fmt": "prettier --write .",
@@ -16,6 +17,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"
}, },

View File

@@ -7,7 +7,13 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
echo "Running tests..." echo "Running tests..."
timeout 30 yarn run test 2>&1 timeout 30 yarn run test 2>&1 || {
echo "--- Rerunning with --verbose for details ---"
timeout 30 yarn run test:verbose 2>&1 || true
# Always fail: the first run already proved the tests are broken, so a
# flaky pass on the rerun must not turn the build green.
exit 1
}
} }
main "$@" main "$@"

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 "$@"

295
script/verify-build Executable file
View File

@@ -0,0 +1,295 @@
#!/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)"
# Absolute path to this script, resolved before anything cd's anywhere.
# check_unlisted_bundles re-invokes it through xargs, and $0 on its own may be
# relative to a directory we are about to leave.
SELF="$(cd "$(dirname "$0")" && pwd -P)/$(basename "$0")"
# Internal re-entry flag; see scan_dist_paths.
SCAN_FLAG="--scan-dist-paths"
# A literal newline, for the is_listed guard.
NEWLINE='
'
MANIFEST="dist/constants-bundles.txt"
MARKER_ON="autistmask-build-debug=on"
MARKER_OFF="autistmask-build-debug=off"
# Set by read_marker.
MARKER=""
# Temporary file holding the NUL-delimited dist/ listing, removed by the EXIT
# trap because fail() exits from wherever it is called.
LISTING=""
fail() {
echo "verify-build: FAIL: $*" >&2
exit 1
}
cleanup() {
[ -z "$LISTING" ] || rm -f "$LISTING"
}
trap cleanup EXIT
# Is the literal $1 present in the file $2? Match (grep exit 0) and no-match
# (exit 1) are answers about the emitted output. Anything else (exit 2: the
# file could not be read) is not an answer at all, and must not be reported as
# "no marker" — that would blame the bundle for a permissions or I/O fault.
has_marker() {
_hm_status=0
grep -q -F -e "$1" -- "$2" || _hm_status=$?
case "$_hm_status" in
0) return 0 ;;
1) return 1 ;;
*)
fail "grep exited $_hm_status reading $2, so the file could not be
searched and its DEBUG state was not checked at all. That is a permissions
or I/O fault on the artifact, not a change in the emitted output. Refusing
to report success."
;;
esac
}
# Does the manifest list the path $1, as a whole line? Same discipline as
# has_marker: exit 0 and 1 are answers about the manifest, exit 2 means the
# manifest could not be read and is not an answer at all. Without this, an
# unreadable manifest reads as "this file is not listed" and every emitted
# bundle gets reported as an unlisted one.
#
# A path containing a newline is answered without asking grep, because grep
# would read the pattern as two patterns and report a match on either. That is
# how such a path escaped this check even once the walk stopped splitting it:
# the half before the newline matched a listed line and the file was skipped.
# The manifest is line-delimited, so it cannot name such a path at all, and
# "not listed" is the only true answer.
is_listed() {
case "$1" in
*"$NEWLINE"*) return 1 ;;
esac
_il_status=0
grep -q -x -F -e "$1" -- "$MANIFEST" || _il_status=$?
case "$_il_status" in
0) return 0 ;;
1) return 1 ;;
*)
fail "grep exited $_il_status reading $MANIFEST, so it could not be
searched and nothing was established about which bundles it lists. That is
a permissions or I/O fault on the manifest, not a stale manifest. Refusing
to report success."
;;
esac
}
# Read one bundle's DEBUG state into MARKER. Exactly one marker must be
# present. Both means the ternary in constants.js was never folded, which is
# what happens when the __BUILD_DEBUG__ define goes missing from build.js:
# DEBUG stops being known at build time. Neither means we are reading output
# we do not understand. Both are hard failures; neither is ever treated as
# absence of a problem.
read_marker() {
_file="$1"
_on=no
_off=no
if has_marker "$MARKER_ON" "$_file"; then _on=yes; fi
if has_marker "$MARKER_OFF" "$_file"; then _off=yes; fi
if [ "$_on" = yes ] && [ "$_off" = yes ]; then
fail "$_file carries both debug markers, so DEBUG was not resolved at
build time: the ternary in src/shared/constants.js survived into the
emitted output. This does not mean the debug branch is live in this
artifact: an unresolved __BUILD_DEBUG__ is undeclared in extension
context, so DEBUG evaluates to false at runtime. It does mean the
release/debug distinction is no longer enforced at build time, and which
way that fallback happens to evaluate is then an accident a refactor can
flip. Check that build.js still defines __BUILD_DEBUG__."
fi
if [ "$_on" = no ] && [ "$_off" = no ]; then
fail "$_file carries no debug marker, so its DEBUG state cannot be
determined. Either BUILD_DEBUG_MARKER is gone from src/shared/constants.js
or the emitted output changed shape. Refusing to report success."
fi
if [ "$_on" = yes ]; then
MARKER="$MARKER_ON"
else
MARKER="$MARKER_OFF"
fi
}
# The manifest says which bundles must carry a marker. This says no other
# emitted file may carry one, which catches a manifest that has gone stale
# or short rather than trusting whatever it happens to list.
#
# Deliberately unfiltered by extension. build.js selects manifest entries with
# an endsWith(".js") test; repeating that literal here would mean a bundle
# emitted under some other extension escaped the manifest AND this check at
# once, which is the correlated blind spot the two-source design exists to
# avoid. Every regular file and every symlink under dist/ is searched — that
# is the whole of what a build emits — so build.js's filter is the only place
# the assumption lives and this check is what catches it being wrong.
#
# That claim only holds if the walk is exhaustive and every name survives it
# intact, so four things are enforced here rather than assumed:
#
# - the walk is NUL-delimited and the paths reach the check as arguments, so
# no name can be reshaped on the way in. Read line by line, a name with a
# trailing space lost it to read's field splitting and the remnant then
# matched a manifest line, and a name containing a newline arrived as a
# listed path plus an empty one. Both left a marker-carrying, unlisted file
# unchecked while the script still reported success. Delivering such a name
# intact is only half of it; is_listed also has to keep it out of grep's
# pattern, for the same reason.
# - find's exit status is checked. A subtree it cannot descend is reported on
# stderr and then simply missing from the listing, so an unchecked status
# turns "could not look" into "nothing was there" — the same conflation
# has_marker exists to prevent. The status cannot be read off a pipeline,
# so the listing lands in a file that xargs then reads back.
# - symlinks are walked too (-type l), not skipped. A marker-carrying bundle
# reachable under an unlisted path in dist/ is a stale manifest whether the
# path is a link or a file, and grep reads through the link. A link that
# cannot be read through — dangling, or pointing at a directory — fails
# hard via has_marker's exit-2 path, which is the fail-closed answer: the
# build emits neither, so their DEBUG state is unproven, not fine.
# - dist/ itself must be a directory and not a symlink, which main asserts
# before anything reads through it. find does not follow a symlink named on
# its own command line, so a linked dist/ collapses this walk to one entry
# and cross-checks nothing.
#
# Types other than regular files and symlinks are left out on purpose: a build
# emits none of them, and grep on a fifo would hang rather than fail.
check_unlisted_bundles() {
LISTING="$(mktemp "${TMPDIR:-/tmp}/verify-build-dist.XXXXXX")" ||
fail "could not create a temporary file for the dist/ listing, so the
tree was never walked. Refusing to report success."
_find_status=0
find dist \( -type f -o -type l \) -print0 >"$LISTING" || _find_status=$?
[ "$_find_status" -eq 0 ] ||
fail "find exited $_find_status enumerating dist/, so part of the tree
was never walked and nothing was established about the files in it. Any
unlisted bundle there went unchecked. That is a permissions or I/O fault on
the artifact, not a stale manifest. Refusing to report success."
_scan_status=0
xargs -0 "$SELF" "$SCAN_FLAG" <"$LISTING" || _scan_status=$?
[ "$_scan_status" -eq 0 ] ||
fail "the unlisted-bundle scan exited $_scan_status: either a path
under dist/ failed the check reported above, or the scan could not be run
at all. Refusing to report success."
}
# The per-path half of check_unlisted_bundles. It runs in a re-invocation of
# this script, so it uses the same is_listed and has_marker as the rest of the
# file rather than a second copy of them that could drift. Paths arrive as
# arguments and are never split, joined or trimmed.
scan_dist_paths() {
for _file in "$@"; do
if is_listed "$_file"; then
continue
fi
if has_marker "$MARKER_ON" "$_file" ||
has_marker "$MARKER_OFF" "$_file"; then
fail "$_file carries a debug marker but is absent from $MANIFEST,
so the manifest no longer describes the emitted bundles."
fi
done
}
# The requested mode, read from our own environment using build.js's exact
# rule: only the literal 1 opts in. Deliberately not taken from anything
# build.js records about itself, so build.js cannot vouch for build.js.
expected_marker() {
if [ "${AUTISTMASK_DEBUG-}" = "1" ]; then
echo "$MARKER_ON"
else
echo "$MARKER_OFF"
fi
}
main() {
cd "$ROOT"
# Internal re-entry from check_unlisted_bundles' xargs. Not part of the
# command-line interface: nothing else invokes it, and it is a distinct
# entry point rather than a mode flag threaded through the checks below.
if [ "${1-}" = "$SCAN_FLAG" ]; then
shift
scan_dist_paths "$@"
return 0
fi
expected="$(expected_marker)"
echo "Verifying emitted bundles (expecting $expected)..."
# Asserted here rather than left to grep. A symlinked dist/ used to fail
# only because GNU grep exits 2 on a directory, so check_unlisted_bundles'
# single entry hit has_marker's I/O path by luck; under a grep that exits 1
# instead, the whole cross-check would have collapsed into a pass.
if [ -h dist ]; then
fail "dist is a symlink, not a directory. find does not follow a
symlink named on its own command line, so the unlisted-bundle cross-check
would see one entry instead of the emitted tree and establish nothing about
it. Refusing to report success."
fi
[ -d dist ] ||
fail "dist is not a directory, so there is no emitted tree to verify.
build.js writes it; run make build first."
[ -f "$MANIFEST" ] ||
fail "$MANIFEST is missing. build.js writes it at the end of a
successful build; run make build first."
[ -s "$MANIFEST" ] ||
fail "$MANIFEST is empty, so no emitted bundle was found to contain
src/shared/constants.js. That is never correct, so it is a failure and not
a pass."
[ -r "$MANIFEST" ] ||
fail "$MANIFEST is not readable, so nothing was inspected. That is a
permissions or I/O fault, not a pass."
count=0
while read -r file; do
[ -n "$file" ] || continue
[ -f "$file" ] ||
fail "$MANIFEST lists $file, which does not exist."
[ -s "$file" ] ||
fail "$MANIFEST lists $file, which is empty. An empty bundle
carries no marker and proves nothing, so this is a failure and not a pass."
read_marker "$file"
[ "$MARKER" = "$expected" ] ||
fail "$file is $MARKER but this build expects $expected."
echo " ok: $file ($MARKER)"
count=$((count + 1))
done <"$MANIFEST"
[ "$count" -gt 0 ] || fail "no bundles were inspected."
check_unlisted_bundles
echo "verify-build: $count bundle(s) verified $expected"
}
main "$@"

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

@@ -5,7 +5,6 @@
const { DEFAULT_RPC_URL } = require("../shared/constants"); const { DEFAULT_RPC_URL } = require("../shared/constants");
const { SUPPORTED_CHAIN_IDS, networkByChainId } = require("../shared/networks"); const { SUPPORTED_CHAIN_IDS, networkByChainId } = require("../shared/networks");
const { onChainSwitch } = require("../shared/chainSwitch"); const { onChainSwitch } = require("../shared/chainSwitch");
const { getBytes } = require("ethers");
const { const {
state, state,
loadState, loadState,
@@ -13,14 +12,20 @@ const {
currentNetwork, currentNetwork,
} = require("../shared/state"); } = require("../shared/state");
const { refreshBalances, getProvider } = require("../shared/balances"); const { refreshBalances, getProvider } = require("../shared/balances");
const { debugFetch } = require("../shared/log"); const { debugFetch, log } = require("../shared/log");
const { decryptWithPassword } = require("../shared/vault"); const { verifySignedTx, verifySignature } = require("../shared/approvalVerify");
const { getSignerForAddress } = require("../shared/wallet");
const { const {
isPhishingDomain, isPhishingDomain,
updatePhishingList, refreshPhishingListOnSchedule,
startPeriodicRefresh, initPhishingList,
} = require("../shared/phishingDomains"); } = require("../shared/phishingDomains");
const {
BALANCE_REFRESH_ALARM,
PHISHING_REFRESH_ALARM,
BALANCE_REFRESH_PERIOD_MINUTES,
ensureRecurringAlarms,
registerAlarmHandlers,
} = require("../shared/alarms");
const storageApi = const storageApi =
typeof browser !== "undefined" typeof browser !== "undefined"
@@ -593,12 +598,22 @@ async function broadcastAccountsChanged() {
// Background balance refresh: every 60 seconds when the popup isn't open. // Background balance refresh: every 60 seconds when the popup isn't open.
// When the popup IS open, its 10-second interval keeps lastBalanceRefresh // When the popup IS open, its 10-second interval keeps lastBalanceRefresh
// fresh, so this naturally skips. // fresh, so this naturally skips.
const BACKGROUND_REFRESH_INTERVAL = 60000; //
// The alarm period alone sets the cadence; this guard only suppresses a
// refresh something else has just done, so it must stay strictly shorter than
// the period. Timed to the period it would veto every tick it gates —
// lastBalanceRefresh is stamped after the refresh runs, so a tick one period
// after the last one always lands inside a guard of equal length and the real
// cadence becomes two periods. Half the period keeps it comfortably above the
// popup's 10-second refresh, so an open popup still suppresses the background
// job, and comfortably below the alarm period, so the schedule always wins.
const BALANCE_REFRESH_PERIOD_MS = BALANCE_REFRESH_PERIOD_MINUTES * 60 * 1000;
const RECENT_BALANCE_REFRESH_MS = Math.floor(BALANCE_REFRESH_PERIOD_MS / 2);
async function backgroundRefresh() { async function backgroundRefresh() {
await loadState(); await loadState();
const now = Date.now(); const now = Date.now();
if (now - (state.lastBalanceRefresh || 0) < BACKGROUND_REFRESH_INTERVAL) if (now - (state.lastBalanceRefresh || 0) < RECENT_BALANCE_REFRESH_MS)
return; return;
if (state.wallets.length === 0) return; if (state.wallets.length === 0) return;
await refreshBalances( await refreshBalances(
@@ -611,12 +626,58 @@ async function backgroundRefresh() {
await saveState(); await saveState();
} }
setInterval(backgroundRefresh, BACKGROUND_REFRESH_INTERVAL); // Both recurring jobs run off alarms, not timers. On Chrome MV3 this file is
// a service worker that the browser terminates after about 30 seconds idle,
// so a setInterval would only ever survive until the first idle period and
// module-level state does not outlive it. Alarms are held by the browser and
// wake the worker to deliver them.
registerAlarmHandlers({
[BALANCE_REFRESH_ALARM]: backgroundRefresh,
// The scheduled refresh, which restores persisted state on a freshly
// revived worker and then fetches unconditionally. The freshness guards
// belong to the startup path; applying them here would make the tick skip
// itself.
[PHISHING_REFRESH_ALARM]: refreshPhishingListOnSchedule,
});
// Fetch the phishing domain blocklist delta on startup and refresh every 24h. // Everything the background context needs re-established on start. This runs
// The vendored blocklist is bundled at build time; this fetches only new entries. // on a fresh install, on browser startup, and on every revival of a
updatePhishingList(); // terminated worker, so it must be idempotent: ensureRecurringAlarms() only
startPeriodicRefresh(); // creates alarms that are missing or carrying a stale period, and
// initPhishingList() fetches only when the persisted timestamps say the list
// is stale.
//
// On a fresh install the top-level call and the onInstalled listener both run,
// close enough together that both could see an alarm missing and create it.
// Sharing one in-flight run makes the "create only when missing" check
// race-free; the memo is dropped once it settles so a later onStartup runs
// again.
let backgroundJobsRun = null;
function startBackgroundJobs() {
if (backgroundJobsRun) return backgroundJobsRun;
backgroundJobsRun = Promise.all([
ensureRecurringAlarms(),
initPhishingList(),
])
.catch((err) => {
// An alarm that failed to schedule means a recurring job silently
// never runs again; it must not be an unhandled rejection.
log.errorf("background job startup failed:", err);
})
.finally(() => {
backgroundJobsRun = null;
});
return backgroundJobsRun;
}
if (runtime.onInstalled) {
runtime.onInstalled.addListener(startBackgroundJobs);
}
if (runtime.onStartup) {
runtime.onStartup.addListener(startBackgroundJobs);
}
startBackgroundJobs();
// When approval window is closed without a response, treat as rejection // When approval window is closed without a response, treat as rejection
if (windowsApi && windowsApi.onRemoved) { if (windowsApi && windowsApi.onRemoved) {
@@ -725,39 +786,28 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
return true; return true;
} }
// The popup signs; it reports back here when it could not. Fail the
// request the same way this handler used to when it did the signing.
if (msg.error) {
approval.resolve({ error: { message: msg.error } });
sendResponse({ error: msg.error });
return false;
}
(async () => { (async () => {
try { try {
await loadState(); await loadState();
const activeAddress = await getActiveAddress(); const activeAddress = await getActiveAddress();
let wallet, addrIndex; // The popup holds the secret, but the background stays the
for (const w of state.wallets) { // authority on what is broadcast: the raw transaction must be
for (let i = 0; i < w.addresses.length; i++) { // the approved one, signed by the approved address.
if (w.addresses[i].address === activeAddress) { verifySignedTx(
wallet = w; msg.rawSignedTx,
addrIndex = i; approval.txParams,
break; activeAddress,
}
}
if (wallet) break;
}
if (!wallet) throw new Error("Wallet not found");
// TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage
let decrypted = await decryptWithPassword(
wallet.encryptedSecret,
msg.password,
); );
const signer = getSignerForAddress(
wallet,
addrIndex,
decrypted,
);
// Best-effort: clear decrypted secret after use.
// Note: JS strings are immutable; this nulls the reference but
// the original string may persist in memory until GC.
decrypted = null;
const provider = getProvider(state.rpcUrl); const provider = getProvider(state.rpcUrl);
const connected = signer.connect(provider); const tx = await provider.broadcastTransaction(msg.rawSignedTx);
const tx = await connected.sendTransaction(approval.txParams);
approval.resolve({ txHash: tx.hash }); approval.resolve({ txHash: tx.hash });
sendResponse({ txHash: tx.hash }); sendResponse({ txHash: tx.hash });
} catch (e) { } catch (e) {
@@ -784,55 +834,23 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
return true; return true;
} }
// The popup signs; it reports back here when it could not. Fail the
// request the same way this handler used to when it did the signing.
if (msg.error) {
approval.resolve({ error: { message: msg.error } });
sendResponse({ error: msg.error });
return false;
}
(async () => { (async () => {
try { try {
await loadState();
const activeAddress = await getActiveAddress(); const activeAddress = await getActiveAddress();
let wallet, addrIndex; // The popup holds the secret, but the background stays the
for (const w of state.wallets) { // authority on what is handed back to the page: the signature
for (let i = 0; i < w.addresses.length; i++) { // must cover the approved payload and recover to the approved
if (w.addresses[i].address === activeAddress) { // address.
wallet = w; const signature = msg.signature;
addrIndex = i; verifySignature(approval.signParams, signature, activeAddress);
break;
}
}
if (wallet) break;
}
if (!wallet) throw new Error("Wallet not found");
// TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage
let decrypted = await decryptWithPassword(
wallet.encryptedSecret,
msg.password,
);
const signer = getSignerForAddress(
wallet,
addrIndex,
decrypted,
);
// Best-effort: clear decrypted secret after use.
// Note: JS strings are immutable; this nulls the reference but
// the original string may persist in memory until GC.
decrypted = null;
const sp = approval.signParams;
let signature;
if (sp.method === "personal_sign" || sp.method === "eth_sign") {
signature = await signer.signMessage(getBytes(sp.message));
} else {
// eth_signTypedData_v4 / eth_signTypedData
const typedData = JSON.parse(sp.typedData);
const { domain, types, message } = typedData;
// ethers handles EIP712Domain internally
delete types.EIP712Domain;
signature = await signer.signTypedData(
domain,
types,
message,
);
}
approval.resolve({ signature }); approval.resolve({ signature });
sendResponse({ signature }); sendResponse({ signature });
} catch (e) { } catch (e) {

View File

@@ -136,7 +136,9 @@
<div id="add-wallet-section-xprv" class="hidden"> <div id="add-wallet-section-xprv" class="hidden">
<p class="mb-2"> <p class="mb-2">
Paste your extended private key (xprv) below. This will Paste your extended private key (xprv) below. This will
import the HD wallet and scan for used addresses. import the HD wallet and scan for used addresses. It
must be the master key for the wallet; an account-level
or child key is not supported.
</p> </p>
<div class="mb-2"> <div class="mb-2">
<input <input
@@ -582,10 +584,18 @@
<div id="confirm-balance" class="text-xs"></div> <div id="confirm-balance" class="text-xs"></div>
</div> </div>
<div id="confirm-fee" class="mb-3" style="visibility: hidden"> <div id="confirm-fee" class="mb-3" style="visibility: hidden">
<div class="text-xs text-muted mb-1"> <div class="text-xs text-muted mb-1">Network fee</div>
Estimated network fee
</div>
<div id="confirm-fee-amount" class="text-xs"></div> <div id="confirm-fee-amount" class="text-xs"></div>
<!-- Holds its one line of space from the first paint, so
the reserve appearing when the estimate lands moves
nothing. The placeholder is never seen. -->
<div
id="confirm-fee-reserve"
class="text-xs text-muted"
style="visibility: hidden"
>
reserve pending
</div>
</div> </div>
<div <div
id="confirm-warnings" id="confirm-warnings"
@@ -647,6 +657,31 @@
class="mb-2 border border-border border-dashed p-2" class="mb-2 border border-border border-dashed p-2"
style="visibility: hidden; min-height: 1.25rem" style="visibility: hidden; min-height: 1.25rem"
></div> ></div>
<div
id="confirm-amount-fee-error"
class="mb-2 border border-border border-dashed p-2 text-xs"
style="visibility: hidden"
>
Your balance does not cover this amount plus the network
fee. Please go back and send a smaller amount.
</div>
<div
id="confirm-gas-error"
class="mb-2 border border-border border-dashed p-2 text-xs"
style="visibility: hidden"
>
You do not have enough ETH to pay the network fee for this
transfer. Please add ETH to this address and try again.
</div>
<div
id="confirm-fee-unknown-error"
class="mb-2 border border-border border-dashed p-2 text-xs"
style="visibility: hidden"
>
The network fee could not be estimated, so this transaction
cannot be checked against your balance. Please go back and
try again.
</div>
<div class="mb-2"> <div class="mb-2">
<label class="block mb-1 text-xs">Password</label> <label class="block mb-1 text-xs">Password</label>
<input <input
@@ -869,6 +904,12 @@
/> />
Show tracked tokens with zero balance Show tracked tokens with zero balance
</label> </label>
<label
class="text-xs flex items-center gap-1 cursor-pointer mb-2"
>
<input type="checkbox" id="settings-utc-timestamps" />
UTC Timestamps
</label>
<div class="text-xs flex items-center gap-1"> <div class="text-xs flex items-center gap-1">
<label for="settings-theme">Theme:</label> <label for="settings-theme">Theme:</label>
<select <select
@@ -948,6 +989,15 @@
transfers and prevent interaction with suspicious transfers and prevent interaction with suspicious
tokens. tokens.
</p> </p>
<label
class="text-xs flex items-center gap-1 cursor-pointer mb-2"
>
<input
type="checkbox"
id="settings-hide-spoofed-symbols"
/>
Hide fake tokens impersonating a known symbol
</label>
<label <label
class="text-xs flex items-center gap-1 cursor-pointer mb-2" class="text-xs flex items-center gap-1 cursor-pointer mb-2"
> >
@@ -979,12 +1029,6 @@
/> />
<span class="text-xs text-muted">gwei</span> <span class="text-xs text-muted">gwei</span>
</div> </div>
<label
class="text-xs flex items-center gap-1 cursor-pointer mb-1"
>
<input type="checkbox" id="settings-utc-timestamps" />
UTC Timestamps
</label>
</div> </div>
<div class="bg-well p-3 mx-1 mb-3"> <div class="bg-well p-3 mx-1 mb-3">
@@ -1002,6 +1046,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 ============ -->
@@ -1040,6 +1142,52 @@
</button> </button>
</div> </div>
<!-- ============ SHOW RECOVERY PHRASE ============ -->
<div id="view-show-phrase" class="view hidden">
<button
id="btn-show-phrase-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
>
&lt; Back
</button>
<h2 class="font-bold mb-1">Recovery Phrase</h2>
<p class="text-xs mb-3" id="show-phrase-wallet-name"></p>
<div
class="text-xs mb-3 border border-border border-dashed p-2"
>
Anyone who has these words can take every coin and token in
this wallet, from any device, without your password. Never
type them into a website and never show them to anyone.
</div>
<div
id="show-phrase-flash"
class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
style="visibility: hidden"
></div>
<div id="show-phrase-password-section" class="mb-2">
<label class="block mb-1">Password</label>
<input
type="password"
id="show-phrase-password"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="Enter your password to continue"
/>
<button
id="btn-show-phrase-reveal"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mt-2"
>
Reveal
</button>
</div>
<div id="show-phrase-result" class="hidden">
<div
id="show-phrase-value"
class="bg-danger-well rounded p-2 font-mono text-xs break-all cursor-pointer mb-1"
title="Click to copy"
></div>
</div>
</div>
<!-- ============ SETTINGS: ADD TOKEN ============ --> <!-- ============ SETTINGS: ADD TOKEN ============ -->
<div id="view-settings-addtoken" class="view hidden"> <div id="view-settings-addtoken" class="view hidden">
<button <button

View File

@@ -1,24 +1,24 @@
// 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,
clearViewStack, clearViewStack,
} = require("./views/helpers"); } = require("./views/helpers");
const { applyTheme } = require("./theme"); const { applyTheme } = require("./theme");
// Views that can be fully re-rendered from persisted state. All others fall
// back to the nearest restorable parent; see the module for why the
// secret-bearing views are absent.
const { RESTORABLE_VIEWS } = require("./restorableViews");
const home = require("./views/home"); const home = require("./views/home");
const welcome = require("./views/welcome"); const welcome = require("./views/welcome");
@@ -103,21 +103,6 @@ const ctx = {
}, },
}; };
// Views that can be fully re-rendered from persisted state.
// All others fall back to the nearest restorable parent.
const RESTORABLE_VIEWS = new Set([
"main",
"address",
"address-token",
"receive",
"settings",
"settings-addtoken",
"confirm-tx",
"transaction",
"success-tx",
"error-tx",
]);
function needsAddress(view) { function needsAddress(view) {
return ( return (
view === "address" || view === "address" ||
@@ -209,21 +194,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

@@ -0,0 +1,29 @@
// Views the popup may reopen onto.
//
// The popup persists the current view so that reopening the toolbar popup
// lands the user back where they were. Only views that can be fully
// re-rendered from persisted state belong here; every other view falls back
// to the nearest restorable parent (src/popup/index.js restoreView()).
//
// A view that displays a secret must NEVER be listed. Restoring onto one
// would put a private key or a recovery phrase on screen with no password
// prompt in front of it, on a popup the user may have reopened by accident.
// That is why "export-privkey" and "show-phrase" are absent.
//
// Kept in its own module, with no dependencies, so tests can assert the
// exclusion directly rather than trusting a reading of the popup entry
// point, which cannot be required outside a browser.
const RESTORABLE_VIEWS = new Set([
"main",
"address",
"address-token",
"receive",
"settings",
"settings-addtoken",
"confirm-tx",
"transaction",
"success-tx",
"error-tx",
]);
module.exports = { RESTORABLE_VIEWS };

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,4 +1,11 @@
const { $, showView, showFlash, goBack, clearViewStack } = require("./helpers"); const {
$,
showView,
showFlash,
goBack,
clearViewStack,
onViewLeave,
} = require("./helpers");
const { const {
generateMnemonic, generateMnemonic,
hdWalletFromMnemonic, hdWalletFromMnemonic,
@@ -6,6 +13,7 @@ const {
addressFromPrivateKey, addressFromPrivateKey,
hdWalletFromXprv, hdWalletFromXprv,
isValidXprv, isValidXprv,
isMasterExtendedKey,
} = require("../../shared/wallet"); } = require("../../shared/wallet");
const { encryptWithPassword } = require("../../shared/vault"); const { encryptWithPassword } = require("../../shared/vault");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
@@ -65,13 +73,23 @@ function switchMode(mode) {
$("add-wallet-password-hint").textContent = PASSWORD_HINTS[mode]; $("add-wallet-password-hint").textContent = PASSWORD_HINTS[mode];
} }
function show() { // Wipe the secret material this screen holds in the DOM: a generated or
// pasted recovery phrase, an imported private key or extended private key,
// and the password that would encrypt them. Registered as the view-leave
// handler as well as run on entry, so none of it survives in the hidden
// view after the user navigates away by any route, including the Settings
// gear and the import itself.
function clear() {
$("wallet-mnemonic").value = ""; $("wallet-mnemonic").value = "";
$("import-private-key").value = ""; $("import-private-key").value = "";
$("import-xprv-key").value = ""; $("import-xprv-key").value = "";
$("add-wallet-password").value = ""; $("add-wallet-password").value = "";
$("add-wallet-password-confirm").value = ""; $("add-wallet-password-confirm").value = "";
$("add-wallet-phrase-warning").style.visibility = "hidden"; $("add-wallet-phrase-warning").style.visibility = "hidden";
}
function show() {
clear();
switchMode("mnemonic"); switchMode("mnemonic");
showView("add-wallet"); showView("add-wallet");
} }
@@ -213,14 +231,25 @@ async function importXprvKey(ctx) {
return; return;
} }
if (!isValidXprv(xprv)) { if (!isValidXprv(xprv)) {
showFlash("Invalid extended private key."); showFlash(
"That extended private key is not valid. Please check it and try again.",
);
return;
}
if (!isMasterExtendedKey(xprv)) {
showFlash(
"That is an account-level or child key, which cannot be imported. " +
"Please paste the master extended private key for the wallet.",
);
return; return;
} }
let result; let result;
try { try {
result = hdWalletFromXprv(xprv); result = hdWalletFromXprv(xprv);
} catch (e) { } catch (e) {
showFlash("Invalid extended private key."); showFlash(
"That extended private key is not valid. Please check it and try again.",
);
return; return;
} }
const { xpub, firstAddress } = result; const { xpub, firstAddress } = result;
@@ -276,6 +305,8 @@ async function importXprvKey(ctx) {
} }
function init(ctx) { function init(ctx) {
onViewLeave("add-wallet", clear);
// Tab click handlers // Tab click handlers
$("tab-mnemonic").addEventListener("click", () => switchMode("mnemonic")); $("tab-mnemonic").addEventListener("click", () => switchMode("mnemonic"));
$("tab-privkey").addEventListener("click", () => switchMode("privkey")); $("tab-privkey").addEventListener("click", () => switchMode("privkey"));

View File

@@ -2,7 +2,6 @@ const {
$, $,
showView, showView,
showFlash, showFlash,
flashCopyFeedback,
balanceLinesForAddress, balanceLinesForAddress,
addressDotHtml, addressDotHtml,
addressTitle, addressTitle,
@@ -27,8 +26,7 @@ const {
} = require("./send"); } = require("./send");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
const makeBlockie = require("ethereum-blockies-base64"); const makeBlockie = require("ethereum-blockies-base64");
const { decryptWithPassword } = require("../../shared/vault"); const exportPrivkey = require("./exportPrivkey");
const { getSignerForAddress } = require("../../shared/wallet");
let ctx; let ctx;
@@ -148,6 +146,7 @@ async function loadTransactions(address) {
state.blockscoutUrl, state.blockscoutUrl,
); );
const result = filterTransactions(rawTxs, { const result = filterTransactions(rawTxs, {
hideSpoofedSymbols: state.hideSpoofedSymbols,
hideLowHolderTokens: state.hideLowHolderTokens, hideLowHolderTokens: state.hideLowHolderTokens,
hideFraudContracts: state.hideFraudContracts, hideFraudContracts: state.hideFraudContracts,
hideDustTransactions: state.hideDustTransactions, hideDustTransactions: state.hideDustTransactions,
@@ -297,81 +296,12 @@ function init(_ctx) {
$("btn-export-privkey").addEventListener("click", () => { $("btn-export-privkey").addEventListener("click", () => {
moreDropdown.classList.add("hidden"); moreDropdown.classList.add("hidden");
moreBtn.classList.remove("bg-fg", "text-bg"); moreBtn.classList.remove("bg-fg", "text-bg");
pushCurrentView(); // No pushCurrentView() here: exportPrivkey.show() can return
const wallet = state.wallets[state.selectedWallet]; // without navigating, so it does its own push.
const addr = wallet.addresses[state.selectedAddress]; exportPrivkey.show(state.selectedWallet, state.selectedAddress);
const blockieEl = $("export-privkey-jazzicon");
blockieEl.innerHTML = "";
const bImg = document.createElement("img");
bImg.src = makeBlockie(addr.address);
bImg.width = 48;
bImg.height = 48;
bImg.style.imageRendering = "pixelated";
bImg.style.borderRadius = "50%";
blockieEl.appendChild(bImg);
$("export-privkey-title").textContent =
wallet.name + " \u2014 Address " + (state.selectedAddress + 1);
const exportAddrContainer = $("export-privkey-dot").parentElement;
exportAddrContainer.innerHTML = renderAddressHtml(addr.address);
attachCopyHandlers(exportAddrContainer);
$("export-privkey-password").value = "";
$("export-privkey-flash").textContent = "";
$("export-privkey-flash").style.visibility = "hidden";
$("export-privkey-password-section").classList.remove("hidden");
$("export-privkey-result").classList.add("hidden");
$("export-privkey-value").textContent = "";
showView("export-privkey");
}); });
$("btn-export-privkey-confirm").addEventListener("click", async () => { exportPrivkey.init();
const password = $("export-privkey-password").value;
if (!password) {
$("export-privkey-flash").textContent = "Password is required.";
$("export-privkey-flash").style.visibility = "visible";
return;
}
const btn = $("btn-export-privkey-confirm");
btn.disabled = true;
btn.classList.add("text-muted");
const wallet = state.wallets[state.selectedWallet];
try {
const secret = await decryptWithPassword(
wallet.encryptedSecret,
password,
);
const signer = getSignerForAddress(
wallet,
state.selectedAddress,
secret,
);
const privateKey = signer.privateKey;
$("export-privkey-password-section").classList.add("hidden");
$("export-privkey-value").textContent = privateKey;
$("export-privkey-result").classList.remove("hidden");
$("export-privkey-flash").style.visibility = "hidden";
} catch {
$("export-privkey-flash").textContent = "Wrong password.";
$("export-privkey-flash").style.visibility = "visible";
} finally {
btn.disabled = false;
btn.classList.remove("text-muted");
}
});
$("export-privkey-value").addEventListener("click", () => {
const key = $("export-privkey-value").textContent;
if (key) {
navigator.clipboard.writeText(key);
showFlash("Copied!");
flashCopyFeedback($("export-privkey-value"));
}
});
$("btn-export-privkey-back").addEventListener("click", () => {
$("export-privkey-value").textContent = "";
$("export-privkey-password").value = "";
goBack();
});
} }
module.exports = { init, show }; module.exports = { init, show };

View File

@@ -222,6 +222,7 @@ async function loadTransactions(address, tokenId) {
state.blockscoutUrl, state.blockscoutUrl,
); );
const result = filterTransactions(rawTxs, { const result = filterTransactions(rawTxs, {
hideSpoofedSymbols: state.hideSpoofedSymbols,
hideLowHolderTokens: state.hideLowHolderTokens, hideLowHolderTokens: state.hideLowHolderTokens,
hideFraudContracts: state.hideFraudContracts, hideFraudContracts: state.hideFraudContracts,
hideDustTransactions: state.hideDustTransactions, hideDustTransactions: state.hideDustTransactions,

View File

@@ -7,12 +7,22 @@ const {
hideError, hideError,
renderAddressHtml, renderAddressHtml,
attachCopyHandlers, attachCopyHandlers,
onViewLeave,
} = require("./helpers"); } = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { formatEther, formatUnits, Interface, toUtf8String } = require("ethers"); const {
formatEther,
formatUnits,
getBytes,
Interface,
toUtf8String,
} = require("ethers");
const { getPrice, formatUsd } = require("../../shared/prices"); const { getPrice, formatUsd } = require("../../shared/prices");
const { ERC20_ABI } = require("../../shared/constants"); const { ERC20_ABI } = require("../../shared/constants");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList"); const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const { decryptWithPassword } = require("../../shared/vault");
const { getSignerForAddress } = require("../../shared/wallet");
const { getProvider } = require("../../shared/balances");
const txStatus = require("./txStatus"); const txStatus = require("./txStatus");
const uniswap = require("../../shared/uniswap"); const uniswap = require("../../shared/uniswap");
const runtime = const runtime =
@@ -153,6 +163,8 @@ function showTxApproval(details) {
details.isPhishingDomain, details.isPhishingDomain,
); );
pendingTxParams = details.txParams;
const toAddr = details.txParams.to; const toAddr = details.txParams.to;
const token = toAddr ? TOKEN_BY_ADDRESS.get(toAddr.toLowerCase()) : null; const token = toAddr ? TOKEN_BY_ADDRESS.get(toAddr.toLowerCase()) : null;
const ethValue = formatEther(details.txParams.value || "0"); const ethValue = formatEther(details.txParams.value || "0");
@@ -326,6 +338,7 @@ function showSignApproval(details) {
); );
const sp = details.signParams; const sp = details.signParams;
pendingSignParams = sp;
$("approve-sign-hostname").textContent = details.hostname; $("approve-sign-hostname").textContent = details.hostname;
$("approve-sign-from").innerHTML = approvalAddressHtml(sp.from); $("approve-sign-from").innerHTML = approvalAddressHtml(sp.from);
@@ -401,8 +414,55 @@ function show(id) {
let approvalId = null; let approvalId = null;
let pendingTxDetails = null; let pendingTxDetails = null;
// The exact parameters shown to the user, kept so the popup signs what it
// displayed rather than re-fetching anything at approval time. Both are
// repopulated by show() when the popup is closed and reopened.
let pendingTxParams = null;
let pendingSignParams = null;
// Approve buttons stay disabled and muted while the popup derives the key and
// signs, which is slow enough (Argon2id) that a double click is likely.
function setTxButtonBusy(busy) {
$("btn-approve-tx").disabled = busy;
$("btn-approve-tx").classList.toggle("text-muted", busy);
}
function setSignButtonBusy(busy) {
$("btn-approve-sign").disabled = busy;
$("btn-approve-sign").classList.toggle("text-muted", busy);
}
// Locate the wallet and the address index owning the currently active
// address. Returns null when no wallet holds it.
function findActiveWallet() {
for (const wallet of state.wallets) {
for (let i = 0; i < wallet.addresses.length; i++) {
if (wallet.addresses[i].address === state.activeAddress) {
return { wallet, addrIndex: i };
}
}
}
return null;
}
// Drop the password from the DOM when either approval screen is left. The
// approval window navigates on after a signature — approve-tx goes to the
// wait screen — and the password must not sit in the hidden view for the
// life of that window.
function clearTxPassword() {
$("approve-tx-password").value = "";
hideError("approve-tx-error");
}
function clearSignPassword() {
$("approve-sign-password").value = "";
hideError("approve-sign-error");
}
function init(ctx) { function init(ctx) {
onViewLeave("approve-tx", clearTxPassword);
onViewLeave("approve-sign", clearSignPassword);
$("approve-remember").addEventListener("change", async () => { $("approve-remember").addEventListener("change", async () => {
state.rememberSiteChoice = $("approve-remember").checked; state.rememberSiteChoice = $("approve-remember").checked;
await saveState(); await saveState();
@@ -430,25 +490,78 @@ function init(ctx) {
window.close(); window.close();
}); });
$("btn-approve-tx").addEventListener("click", () => { $("btn-approve-tx").addEventListener("click", async () => {
const password = $("approve-tx-password").value; let password = $("approve-tx-password").value;
if (!password) { if (!password) {
showError("approve-tx-error", "Please enter your password."); showError("approve-tx-error", "Please enter your password.");
return; return;
} }
hideError("approve-tx-error"); hideError("approve-tx-error");
$("btn-approve-tx").disabled = true; setTxButtonBusy(true);
$("btn-approve-tx").classList.add("text-muted");
runtime.sendMessage( const active = findActiveWallet();
{ if (!active) {
password = null;
showError(
"approve-tx-error",
"No wallet was found for the active address.",
);
setTxButtonBusy(false);
return;
}
// Decrypt here, in the popup. The password must never cross the
// extension messaging boundary; only the signed transaction does.
let decryptedSecret;
try {
decryptedSecret = await decryptWithPassword(
active.wallet.encryptedSecret,
password,
);
} catch {
showError(
"approve-tx-error",
"That password is incorrect. Please try again.",
);
setTxButtonBusy(false);
return;
} finally {
// Best-effort: drop the password as soon as the key derivation
// is done. Note that JS strings are immutable; this clears the
// reference but the original string may persist until GC.
password = null;
}
const payload = {
type: "AUTISTMASK_TX_RESPONSE", type: "AUTISTMASK_TX_RESPONSE",
id: approvalId, id: approvalId,
approved: true, approved: true,
// TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage };
password: password, try {
}, const signer = getSignerForAddress(
(response) => { active.wallet,
active.addrIndex,
decryptedSecret,
);
const provider = getProvider(state.rpcUrl);
const connected = signer.connect(provider);
// This is the sequence ethers' own sendTransaction() runs
// internally, so nonce, gas, fee and chain id population are
// identical to when the background did the signing.
const populated =
await connected.populateTransaction(pendingTxParams);
delete populated.from;
payload.rawSignedTx = await connected.signTransaction(populated);
} catch (e) {
payload.error =
e.shortMessage || e.message || "Transaction signing failed.";
} finally {
// Best-effort: clear the decrypted secret after use, with the
// same immutability caveat as the password above.
decryptedSecret = null;
}
runtime.sendMessage(payload, (response) => {
if (response && response.txHash) { if (response && response.txHash) {
txStatus.showWait(pendingTxDetails, response.txHash); txStatus.showWait(pendingTxDetails, response.txHash);
} else { } else {
@@ -456,8 +569,7 @@ function init(ctx) {
(response && response.error) || "Transaction failed."; (response && response.error) || "Transaction failed.";
txStatus.showError(pendingTxDetails, null, msg); txStatus.showError(pendingTxDetails, null, msg);
} }
}, });
);
}); });
$("btn-reject-tx").addEventListener("click", () => { $("btn-reject-tx").addEventListener("click", () => {
@@ -469,36 +581,93 @@ function init(ctx) {
window.close(); window.close();
}); });
$("btn-approve-sign").addEventListener("click", () => { $("btn-approve-sign").addEventListener("click", async () => {
const password = $("approve-sign-password").value; let password = $("approve-sign-password").value;
if (!password) { if (!password) {
showError("approve-sign-error", "Please enter your password."); showError("approve-sign-error", "Please enter your password.");
return; return;
} }
hideError("approve-sign-error"); hideError("approve-sign-error");
$("btn-approve-sign").disabled = true; setSignButtonBusy(true);
$("btn-approve-sign").classList.add("text-muted");
runtime.sendMessage( const active = findActiveWallet();
{ if (!active) {
password = null;
showError(
"approve-sign-error",
"No wallet was found for the active address.",
);
setSignButtonBusy(false);
return;
}
// Decrypt here, in the popup. The password must never cross the
// extension messaging boundary; only the signature does.
let decryptedSecret;
try {
decryptedSecret = await decryptWithPassword(
active.wallet.encryptedSecret,
password,
);
} catch {
showError(
"approve-sign-error",
"That password is incorrect. Please try again.",
);
setSignButtonBusy(false);
return;
} finally {
// Best-effort: drop the password as soon as the key derivation
// is done. Note that JS strings are immutable; this clears the
// reference but the original string may persist until GC.
password = null;
}
const payload = {
type: "AUTISTMASK_SIGN_RESPONSE", type: "AUTISTMASK_SIGN_RESPONSE",
id: approvalId, id: approvalId,
approved: true, approved: true,
// TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage };
password: password, try {
}, const signer = getSignerForAddress(
(response) => { active.wallet,
active.addrIndex,
decryptedSecret,
);
const sp = pendingSignParams;
if (sp.method === "personal_sign" || sp.method === "eth_sign") {
payload.signature = await signer.signMessage(
getBytes(sp.message),
);
} else {
// eth_signTypedData_v4 / eth_signTypedData
const typedData = JSON.parse(sp.typedData);
const { domain, types, message } = typedData;
// ethers handles EIP712Domain internally
delete types.EIP712Domain;
payload.signature = await signer.signTypedData(
domain,
types,
message,
);
}
} catch (e) {
payload.error = e.shortMessage || e.message || "Signing failed.";
} finally {
// Best-effort: clear the decrypted secret after use, with the
// same immutability caveat as the password above.
decryptedSecret = null;
}
runtime.sendMessage(payload, (response) => {
if (response && response.signature) { if (response && response.signature) {
window.close(); window.close();
} else { } else {
const msg = const msg = (response && response.error) || "Signing failed.";
(response && response.error) || "Signing failed.";
showError("approve-sign-error", msg); showError("approve-sign-error", msg);
$("btn-approve-sign").disabled = false; setSignButtonBusy(false);
$("btn-approve-sign").classList.remove("text-muted");
} }
}, });
);
}); });
$("btn-reject-sign").addEventListener("click", () => { $("btn-reject-sign").addEventListener("click", () => {

View File

@@ -21,6 +21,7 @@ const {
renderAddressHtml, renderAddressHtml,
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
onViewLeave,
} = require("./helpers"); } = require("./helpers");
const { state, currentNetwork } = require("../../shared/state"); const { state, currentNetwork } = require("../../shared/state");
const { getSignerForAddress } = require("../../shared/wallet"); const { getSignerForAddress } = require("../../shared/wallet");
@@ -32,11 +33,24 @@ const {
getFullWarnings, getFullWarnings,
} = require("../../shared/addressWarnings"); } = require("../../shared/addressWarnings");
const { ERC20_ABI, isBurnAddress } = require("../../shared/constants"); const { ERC20_ABI, isBurnAddress } = require("../../shared/constants");
const {
CODES,
FEE_PENDING,
FEE_KNOWN,
FEE_UNAVAILABLE,
feeReserveWei,
feeEstimateWei,
validateTransfer,
} = require("../../shared/txValidation");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
const makeBlockie = require("ethereum-blockies-base64"); const makeBlockie = require("ethereum-blockies-base64");
const txStatus = require("./txStatus"); const txStatus = require("./txStatus");
let pendingTx = null; let pendingTx = null;
// Network fee for the transaction currently on screen. Reset by show() and
// filled in by estimateGas() when the estimate resolves or fails.
let feeStatus = FEE_PENDING;
let feeWei = null;
function restore() { function restore() {
const d = state.viewData; const d = state.viewData;
@@ -67,6 +81,8 @@ function valueWithUsd(text, usdAmount) {
function show(txInfo) { function show(txInfo) {
pendingTx = txInfo; pendingTx = txInfo;
feeStatus = FEE_PENDING;
feeWei = null;
const isErc20 = txInfo.token !== "ETH"; const isErc20 = txInfo.token !== "ETH";
const symbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH"; const symbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH";
@@ -153,50 +169,14 @@ function show(txInfo) {
warningsEl.style.visibility = "hidden"; warningsEl.style.visibility = "hidden";
} }
// Check for errors // The two fee messages are mutually exclusive per transaction type, and
const errors = []; // the type is known here, before the first paint. Drop the one that can
if (isErc20) { // never apply and reserve the space of the one that can, so the async
const tokenBal = parseFloat(txInfo.tokenBalance || "0"); // estimate landing later never moves anything.
if (parseFloat(txInfo.amount) > tokenBal) { $("confirm-amount-fee-error").classList.toggle("hidden", isErc20);
errors.push( $("confirm-gas-error").classList.toggle("hidden", !isErc20);
"Insufficient " +
symbol +
" balance. You have " +
txInfo.tokenBalance +
" " +
symbol +
" but are trying to send " +
txInfo.amount +
" " +
symbol +
".",
);
}
} else if (parseFloat(txInfo.amount) > parseFloat(txInfo.balance)) {
errors.push(
"Insufficient balance. You have " +
txInfo.balance +
" ETH but are trying to send " +
txInfo.amount +
" ETH.",
);
}
const errorsEl = $("confirm-errors"); renderValidation(txInfo);
const sendBtn = $("btn-confirm-send");
if (errors.length > 0) {
errorsEl.innerHTML = errors
.map((e) => `<div class="text-xs">${e}</div>`)
.join("");
errorsEl.style.visibility = "visible";
sendBtn.disabled = true;
sendBtn.classList.add("text-muted");
} else {
errorsEl.innerHTML = "";
errorsEl.style.visibility = "hidden";
sendBtn.disabled = false;
sendBtn.classList.remove("text-muted");
}
// Reset password field and error // Reset password field and error
$("confirm-tx-password").value = ""; $("confirm-tx-password").value = "";
@@ -205,6 +185,7 @@ function show(txInfo) {
// Gas estimate — show placeholder then fetch async // Gas estimate — show placeholder then fetch async
$("confirm-fee").style.visibility = "visible"; $("confirm-fee").style.visibility = "visible";
$("confirm-fee-amount").textContent = "Estimating..."; $("confirm-fee-amount").textContent = "Estimating...";
setVisible("confirm-fee-reserve", false);
state.viewData = { pendingTx: txInfo }; state.viewData = { pendingTx: txInfo };
showView("confirm-tx"); showView("confirm-tx");
attachCopyHandlers("view-confirm-tx"); attachCopyHandlers("view-confirm-tx");
@@ -224,11 +205,101 @@ function show(txInfo) {
checkRecipientHistory(txInfo); checkRecipientHistory(txInfo);
} }
// Render the balance check for the transaction on screen. Called once during
// show() and again when the fee estimate resolves or fails. Every element it
// touches already occupies its space, so re-running it never moves anything.
function renderValidation(txInfo) {
const isErc20 = txInfo.token !== "ETH";
const symbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH";
const { canSend, codes } = validateTransfer({
isErc20,
amount: txInfo.amount,
ethBalance: txInfo.balance,
tokenBalance: txInfo.tokenBalance,
feeStatus,
feeWei,
});
// Messages carrying the user's own numbers are built here; the fixed
// sentences live in the reserved elements in index.html.
const messages = [];
if (codes.includes(CODES.AMOUNT_INVALID)) {
messages.push("Please enter a valid amount to send.");
}
if (codes.includes(CODES.INSUFFICIENT_TOKEN)) {
messages.push(
"Insufficient " +
symbol +
" balance. You have " +
txInfo.tokenBalance +
" " +
symbol +
" but are trying to send " +
txInfo.amount +
" " +
symbol +
".",
);
}
if (codes.includes(CODES.INSUFFICIENT_ETH)) {
messages.push(
"Insufficient balance. You have " +
txInfo.balance +
" ETH but are trying to send " +
txInfo.amount +
" ETH.",
);
}
const errorsEl = $("confirm-errors");
if (messages.length > 0) {
errorsEl.innerHTML = messages
.map((m) => `<div class="text-xs">${escapeHtml(m)}</div>`)
.join("");
errorsEl.style.visibility = "visible";
} else {
errorsEl.innerHTML = "";
errorsEl.style.visibility = "hidden";
}
setVisible(
"confirm-amount-fee-error",
codes.includes(CODES.INSUFFICIENT_ETH_WITH_FEE),
);
setVisible(
"confirm-gas-error",
codes.includes(CODES.INSUFFICIENT_ETH_FOR_FEE),
);
setVisible(
"confirm-fee-unknown-error",
codes.includes(CODES.FEE_UNAVAILABLE),
);
// While the estimate is in flight there is no error to show — the fee
// line already reads "Estimating..." — but sending stays blocked so a
// transaction the fee would break cannot be signed in the meantime.
const sendBtn = $("btn-confirm-send");
sendBtn.disabled = !canSend;
sendBtn.classList.toggle("text-muted", !canSend);
}
function setVisible(id, visible) {
$(id).style.visibility = visible ? "visible" : "hidden";
}
// A fee in wei as an ETH string, truncated to 6 decimal places.
function formatFeeEth(wei) {
const parts = formatEther(wei).split(".");
const dec =
parts.length > 1 ? parts[1].slice(0, 6).replace(/0+$/, "") || "0" : "0";
return parts[0] + "." + dec + " ETH";
}
async function estimateGas(txInfo) { async function estimateGas(txInfo) {
try { try {
const provider = getProvider(state.rpcUrl); const provider = getProvider(state.rpcUrl);
const feeData = await provider.getFeeData(); const feeData = await provider.getFeeData();
const gasPrice = feeData.gasPrice;
let gasLimit; let gasLimit;
if (txInfo.token === "ETH") { if (txInfo.token === "ETH") {
@@ -246,21 +317,55 @@ async function estimateGas(txInfo) {
}); });
} }
const gasCostWei = gasLimit * gasPrice; // What the node will require to be reserved, which is what the gate
const gasCostEth = formatEther(gasCostWei); // must be: the send pins no fee fields, so it is broadcast as a
// Format to 6 significant decimal places // type-2 transaction priced at maxFeePerGas.
const parts = gasCostEth.split("."); const gasCostWei = feeReserveWei(gasLimit, feeData);
const dec = if (gasCostWei === null) {
parts.length > 1 throw new Error("no usable gas price from the provider");
? parts[1].slice(0, 6).replace(/0+$/, "") || "0" }
: "0"; // What the transaction is expected to cost, which is a different and
const feeStr = parts[0] + "." + dec + " ETH"; // usually much smaller number. Both are shown: quoting only the
// reserve overstates the typical cost by roughly double on mainnet,
// and quoting only the estimate contradicts the balance check.
const estimateWei = feeEstimateWei(gasLimit, feeData);
// The user may have left this transaction while the estimate was in
// flight; a stale fee must not reach the screen or the balance check.
if (pendingTx !== txInfo) return;
const ethPrice = getPrice("ETH"); const ethPrice = getPrice("ETH");
const feeUsd = ethPrice ? parseFloat(gasCostEth) * ethPrice : null; const usd = (wei) =>
$("confirm-fee-amount").textContent = valueWithUsd(feeStr, feeUsd); ethPrice ? parseFloat(formatEther(wei)) * ethPrice : null;
if (estimateWei !== null && estimateWei < gasCostWei) {
$("confirm-fee-amount").textContent = valueWithUsd(
"~" + formatFeeEth(estimateWei),
usd(estimateWei),
);
$("confirm-fee-reserve").textContent =
"up to " + formatFeeEth(gasCostWei) + " reserved";
setVisible("confirm-fee-reserve", true);
} else {
// No spread to report: either there is no estimate, or the node
// quotes a gas price at or above maxFeePerGas, so the expected
// cost is not below the reserve. Show the reserve alone.
$("confirm-fee-amount").textContent = valueWithUsd(
formatFeeEth(gasCostWei),
usd(gasCostWei),
);
setVisible("confirm-fee-reserve", false);
}
feeStatus = FEE_KNOWN;
feeWei = gasCostWei;
renderValidation(txInfo);
} catch (e) { } catch (e) {
log.errorf("gas estimation failed:", e.message); log.errorf("gas estimation failed:", e.message);
if (pendingTx !== txInfo) return;
$("confirm-fee-amount").textContent = "Unable to estimate"; $("confirm-fee-amount").textContent = "Unable to estimate";
setVisible("confirm-fee-reserve", false);
feeStatus = FEE_UNAVAILABLE;
feeWei = null;
renderValidation(txInfo);
} }
} }
@@ -286,7 +391,17 @@ async function checkRecipientHistory(txInfo) {
} }
} }
// Drop the password from the DOM. Registered as the view-leave handler so
// it does not sit in the hidden view once the screen navigates on — to the
// wait screen after a send, or anywhere else the user goes.
function clearPassword() {
$("confirm-tx-password").value = "";
hideError("confirm-tx-password-error");
}
function init(ctx) { function init(ctx) {
onViewLeave("confirm-tx", clearPassword);
$("btn-confirm-send").addEventListener("click", async () => { $("btn-confirm-send").addEventListener("click", async () => {
const password = $("confirm-tx-password").value; const password = $("confirm-tx-password").value;
if (!password) { if (!password) {

View File

@@ -1,26 +1,49 @@
const { $, showView, showFlash, goBack, clearViewStack } = require("./helpers"); const {
$,
showView,
showFlash,
goBack,
clearViewStack,
onViewLeave,
} = require("./helpers");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { decryptWithPassword } = require("../../shared/vault"); const { decryptWithPassword } = require("../../shared/vault");
const {
removeWalletFromState,
broadcastActiveChanged,
} = require("../../shared/walletDelete");
let deleteWalletIndex = null; let deleteWalletIndex = null;
let ctx = null; let ctx = null;
// Drop the password from the DOM and the wallet selection from the
// closure. Registered as the view-leave handler as well as run on entry,
// so the typed password does not sit in the hidden view after the user
// navigates away by any route, including the Settings gear.
function clear() {
deleteWalletIndex = null;
$("delete-wallet-password").value = "";
$("delete-wallet-flash").textContent = "";
$("delete-wallet-flash").style.visibility = "hidden";
}
function show(walletIdx) { function show(walletIdx) {
clear();
deleteWalletIndex = walletIdx; deleteWalletIndex = walletIdx;
const wallet = state.wallets[walletIdx]; const wallet = state.wallets[walletIdx];
$("delete-wallet-name").textContent = $("delete-wallet-name").textContent =
wallet.name || "Wallet " + (walletIdx + 1); wallet.name || "Wallet " + (walletIdx + 1);
$("delete-wallet-password").value = "";
$("delete-wallet-flash").textContent = "";
$("delete-wallet-flash").style.visibility = "hidden";
showView("delete-wallet-confirm"); showView("delete-wallet-confirm");
} }
function init(_ctx) { function init(_ctx) {
ctx = _ctx; ctx = _ctx;
onViewLeave("delete-wallet-confirm", clear);
// No wipe here: goBack() routes through showView(), which runs the
// leave hook.
$("btn-delete-wallet-back").addEventListener("click", () => { $("btn-delete-wallet-back").addEventListener("click", () => {
deleteWalletIndex = null;
goBack(); goBack();
}); });
@@ -58,35 +81,24 @@ function init(_ctx) {
return; return;
} }
// Collect addresses to clean up from allowedSites/deniedSites // Remove the wallet and repair selection, permissions and hasWallet
const addresses = (wallet.addresses || []).map((a) => a.address); const { activeAddressChanged } = removeWalletFromState(
state,
// Remove wallet walletIdx,
state.wallets.splice(walletIdx, 1); );
// Clean up site permissions for deleted addresses
for (const addr of addresses) {
delete state.allowedSites[addr];
delete state.deniedSites[addr];
}
deleteWalletIndex = null; deleteWalletIndex = null;
if (state.wallets.length === 0) { if (!state.hasWallet) {
// No wallets left — reset selection and show welcome
state.selectedWallet = null;
state.selectedAddress = null;
state.activeAddress = null;
clearViewStack(); clearViewStack();
await saveState(); await saveState();
// Save before broadcasting: the background reads the active
// address back out of storage to build accountsChanged.
if (activeAddressChanged) broadcastActiveChanged();
showView("welcome"); showView("welcome");
} else { } else {
// Switch to first wallet if deleted wallet was active
state.selectedWallet = 0;
state.selectedAddress = 0;
state.activeAddress =
state.wallets[0].addresses[0]?.address || null;
await saveState(); await saveState();
if (activeAddressChanged) broadcastActiveChanged();
// Reset stack to [main] so Settings back goes home. // Reset stack to [main] so Settings back goes home.
// Use require() lazily to avoid circular dependency // Use require() lazily to avoid circular dependency
// (settings.js requires deleteWallet.js). // (settings.js requires deleteWallet.js).

View File

@@ -0,0 +1,174 @@
// Private key export for a single address.
//
// The key controls the address outright — anyone holding it can move every
// token in it, from any device, forever — so this screen is handled under
// the same rules as the recovery phrase screen (./showPhrase.js):
//
// 1. Nothing is decrypted, no key is derived, and nothing is written into
// the DOM until decryptWithPassword has accepted the password.
// 2. Leaving the screen by any path wipes it, via the onViewLeave hook,
// and a decrypt still in flight when that happens is discarded
// instead of written (revealGeneration).
// 3. The key never reaches the logger. This module deliberately does not
// import src/shared/log.js.
//
// The key is also never assigned to `state`, so it cannot be persisted to
// extension storage, and "export-privkey" is excluded from RESTORABLE_VIEWS
// so the popup can never reopen onto it.
const {
$,
showView,
showFlash,
flashCopyFeedback,
goBack,
onViewLeave,
pushCurrentView,
renderAddressHtml,
attachCopyHandlers,
} = require("./helpers");
const { state } = require("../../shared/state");
const { decryptWithPassword } = require("../../shared/vault");
const { getSignerForAddress } = require("../../shared/wallet");
const makeBlockie = require("ethereum-blockies-base64");
const VIEW = "export-privkey";
let walletIndex = null;
let addressIndex = null;
// Bumped by every clear(), which is what leaving the screen runs. reveal()
// captures it before awaiting the decrypt and refuses to touch the DOM if
// it has moved: a decrypt still in flight when the screen is left would
// otherwise write the key *after* the wipe, with nothing scheduled to wipe
// it again, leaving it in the hidden view for the life of the popup.
let revealGeneration = 0;
// True only if the reveal that captured `generation` is still the live one:
// the screen has not been left, cleared, or re-entered for another address
// since it started.
function isCurrentReveal(generation) {
return (
generation === revealGeneration &&
walletIndex !== null &&
addressIndex !== null &&
state.currentView === VIEW
);
}
function fail(message) {
$("export-privkey-flash").textContent = message;
$("export-privkey-flash").style.visibility = "visible";
}
// Wipe every trace of the key and drop the address selection. Safe to call
// when nothing was ever revealed, and safe to call twice.
function clear() {
walletIndex = null;
addressIndex = null;
revealGeneration += 1;
$("export-privkey-value").textContent = "";
$("export-privkey-password").value = "";
$("export-privkey-result").classList.add("hidden");
$("export-privkey-password-section").classList.remove("hidden");
$("export-privkey-flash").textContent = "";
$("export-privkey-flash").style.visibility = "hidden";
}
function show(walletIdx, addrIdx) {
const wallet = state.wallets[walletIdx];
const addr = wallet && wallet.addresses[addrIdx];
if (!addr) {
showFlash("That address is no longer available.");
return;
}
clear();
walletIndex = walletIdx;
addressIndex = addrIdx;
const blockieEl = $("export-privkey-jazzicon");
blockieEl.innerHTML = "";
const img = document.createElement("img");
img.src = makeBlockie(addr.address);
img.width = 48;
img.height = 48;
img.style.imageRendering = "pixelated";
img.style.borderRadius = "50%";
blockieEl.appendChild(img);
$("export-privkey-title").textContent =
wallet.name + " — Address " + (addrIdx + 1);
const addrContainer = $("export-privkey-dot").parentElement;
addrContainer.innerHTML = renderAddressHtml(addr.address);
attachCopyHandlers(addrContainer);
// Pushed here rather than by the caller: this function can return
// without navigating, and a push that happened anyway would leave an
// entry on the stack that no screen transition matches.
pushCurrentView();
showView(VIEW);
}
async function reveal() {
const password = $("export-privkey-password").value;
if (!password) {
fail("Password is required.");
return;
}
if (walletIndex === null) {
fail("No address is selected.");
return;
}
const wallet = state.wallets[walletIndex];
const btn = $("btn-export-privkey-confirm");
btn.disabled = true;
btn.classList.add("text-muted");
const generation = revealGeneration;
try {
const secret = await decryptWithPassword(
wallet.encryptedSecret,
password,
);
// The only suspension point in this view, and the gate on the only
// place a secret is written: if the screen was left while the
// decrypt ran, the wipe has already happened, so the key is not
// even derived, let alone written.
if (!isCurrentReveal(generation)) return;
const signer = getSignerForAddress(wallet, addressIndex, secret);
$("export-privkey-password").value = "";
$("export-privkey-password-section").classList.add("hidden");
$("export-privkey-value").textContent = signer.privateKey;
$("export-privkey-result").classList.remove("hidden");
$("export-privkey-flash").textContent = "";
$("export-privkey-flash").style.visibility = "hidden";
} catch {
if (!isCurrentReveal(generation)) return;
fail("That password is not correct. Please try again.");
} finally {
btn.disabled = false;
btn.classList.remove("text-muted");
}
}
function init() {
onViewLeave(VIEW, clear);
// No wipe here: goBack() routes through showView(), which runs the
// leave hook. A per-button wipe would only cover this one path.
$("btn-export-privkey-back").addEventListener("click", () => {
goBack();
});
$("btn-export-privkey-confirm").addEventListener("click", reveal);
$("export-privkey-value").addEventListener("click", () => {
const key = $("export-privkey-value").textContent;
if (!key) return;
navigator.clipboard.writeText(key);
showFlash("Copied!");
flashCopyFeedback($("export-privkey-value"));
});
}
module.exports = { init, show };

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,
@@ -31,8 +31,20 @@ const VIEWS = [
"approve-tx", "approve-tx",
"approve-sign", "approve-sign",
"export-privkey", "export-privkey",
"show-phrase",
]; ];
// Cleanup callbacks for views that hold a secret in the DOM. The view
// registers one for itself and showView() runs it whenever that view is
// navigated away from, so the secret is wiped no matter which control
// caused the navigation — "Back", the settings gear, or a jump from
// anywhere else. A per-button clear would only cover the one path.
const viewLeaveHandlers = new Map();
function onViewLeave(name, fn) {
viewLeaveHandlers.set(name, fn);
}
function $(id) { function $(id) {
return document.getElementById(id); return document.getElementById(id);
} }
@@ -50,6 +62,11 @@ function hideError(id) {
} }
function showView(name) { function showView(name) {
const leaving = state.currentView;
if (leaving && leaving !== name) {
const onLeave = viewLeaveHandlers.get(leaving);
if (onLeave) onLeave();
}
for (const v of VIEWS) { for (const v of VIEWS) {
const el = document.getElementById(`view-${v}`); const el = document.getElementById(`view-${v}`);
if (el) { if (el) {
@@ -59,19 +76,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";
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);
}
const suffix = viewName ? " (" + viewName + ")" : "";
if (debug && net.isTestnet) {
banner.textContent = "DEBUG / INSECURE [TESTNET]" + suffix;
} else if (net.isTestnet) { } else if (net.isTestnet) {
banner.textContent = "[TESTNET]"; banner.textContent = "[TESTNET]" + suffix;
} else { } else {
banner.textContent = "DEBUG / INSECURE (" + name + ")"; banner.textContent = "DEBUG / INSECURE" + suffix;
}
} }
} else if (banner) {
banner.remove();
} }
} }
@@ -413,10 +448,13 @@ function flashCopyFeedback(el) {
} }
module.exports = { module.exports = {
VIEWS,
$, $,
showError, showError,
hideError, hideError,
showView, showView,
onViewLeave,
updateDebugBanner,
setRenderMain, setRenderMain,
pushCurrentView, pushCurrentView,
goBack, goBack,

View File

@@ -163,6 +163,7 @@ async function loadHomeTxs(ctx) {
if (allAddresses.length === 0) return; if (allAddresses.length === 0) return;
const filters = { const filters = {
hideSpoofedSymbols: state.hideSpoofedSymbols,
hideLowHolderTokens: state.hideLowHolderTokens, hideLowHolderTokens: state.hideLowHolderTokens,
hideFraudContracts: state.hideFraudContracts, hideFraudContracts: state.hideFraudContracts,
hideDustTransactions: state.hideDustTransactions, hideDustTransactions: state.hideDustTransactions,

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,25 @@ 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 showPhrase = require("./showPhrase");
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
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())];
@@ -86,7 +101,14 @@ function renderWalletListSettings() {
const name = escapeHtml(wallet.name || "Wallet " + (idx + 1)); const name = escapeHtml(wallet.name || "Wallet " + (idx + 1));
html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`; html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`;
html += `<span class="settings-wallet-name cursor-pointer underline decoration-dashed" data-idx="${idx}">${name}</span>`; html += `<span class="settings-wallet-name cursor-pointer underline decoration-dashed" data-idx="${idx}">${name}</span>`;
html += `<span class="flex items-center gap-1 flex-shrink-0">`;
// Key and xprv wallets have no recovery phrase, so they are never
// offered the action at all.
if (walletHasRecoveryPhrase(wallet)) {
html += `<button class="btn-show-phrase border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-idx="${idx}" title="Show recovery phrase">[recovery phrase]</button>`;
}
html += `<button class="btn-delete-wallet border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-idx="${idx}">[x]</button>`; html += `<button class="btn-delete-wallet border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-idx="${idx}">[x]</button>`;
html += `</span>`;
html += `</div>`; html += `</div>`;
}); });
container.innerHTML = html; container.innerHTML = html;
@@ -98,6 +120,15 @@ function renderWalletListSettings() {
}); });
}); });
container.querySelectorAll(".btn-show-phrase").forEach((btn) => {
btn.addEventListener("click", () => {
const idx = parseInt(btn.dataset.idx, 10);
// No pushCurrentView() here: showPhrase.show() refuses
// non-HD wallets and pushes only when it navigates.
showPhrase.show(idx);
});
});
// Inline rename on click // Inline rename on click
container.querySelectorAll(".settings-wallet-name").forEach((span) => { container.querySelectorAll(".settings-wallet-name").forEach((span) => {
span.addEventListener("click", () => { span.addEventListener("click", () => {
@@ -142,6 +173,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");
} }
@@ -156,6 +209,7 @@ function renderSiteLists() {
function init(ctx) { function init(ctx) {
deleteWallet.init(ctx); deleteWallet.init(ctx);
showPhrase.init();
$("btn-save-rpc").addEventListener("click", async () => { $("btn-save-rpc").addEventListener("click", async () => {
const url = $("settings-rpc").value.trim(); const url = $("settings-rpc").value.trim();
@@ -249,6 +303,12 @@ function init(ctx) {
applyTheme(state.theme); applyTheme(state.theme);
}); });
$("settings-hide-spoofed-symbols").checked = state.hideSpoofedSymbols;
$("settings-hide-spoofed-symbols").addEventListener("change", async () => {
state.hideSpoofedSymbols = $("settings-hide-spoofed-symbols").checked;
await saveState();
});
$("settings-hide-low-holders").checked = state.hideLowHolderTokens; $("settings-hide-low-holders").checked = state.hideLowHolderTokens;
$("settings-hide-low-holders").addEventListener("change", async () => { $("settings-hide-low-holders").addEventListener("change", async () => {
state.hideLowHolderTokens = $("settings-hide-low-holders").checked; state.hideLowHolderTokens = $("settings-hide-low-holders").checked;
@@ -269,11 +329,17 @@ function init(ctx) {
$("settings-dust-threshold").value = state.dustThresholdGwei; $("settings-dust-threshold").value = state.dustThresholdGwei;
$("settings-dust-threshold").addEventListener("change", async () => { $("settings-dust-threshold").addEventListener("change", async () => {
const val = parseInt($("settings-dust-threshold").value, 10); const raw = $("settings-dust-threshold").value.trim();
if (!isNaN(val) && val >= 0) { const val = Number(raw);
// 0 is accepted and means "hide nothing". Empty, negative,
// fractional and non-numeric input is rejected outright rather than
// coerced, and the field is put back to the stored threshold so it
// never shows a value the wallet is not using.
if (raw !== "" && Number.isInteger(val) && val >= 0) {
state.dustThresholdGwei = val; state.dustThresholdGwei = val;
await saveState(); await saveState();
} }
$("settings-dust-threshold").value = state.dustThresholdGwei;
}); });
$("settings-utc-timestamps").checked = state.utcTimestamps; $("settings-utc-timestamps").checked = state.utcTimestamps;
@@ -289,6 +355,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

@@ -0,0 +1,154 @@
// Recovery phrase display for HD wallets.
//
// The phrase is the secret that owns every address in the wallet, so it is
// handled under four rules:
//
// 1. Only an HD wallet reaches this screen (walletHasRecoveryPhrase).
// 2. Nothing is decrypted, and nothing is written into the DOM, until
// decryptWithPassword has accepted the password.
// 3. Leaving the screen by any path wipes it, via the onViewLeave hook,
// and a decrypt still in flight when that happens is discarded
// instead of written (revealGeneration).
// 4. The phrase never reaches the logger. This module deliberately does
// not import src/shared/log.js, and the failed-decrypt path reports a
// fixed sentence rather than the caught error.
//
// The phrase is also never assigned to `state`, so it cannot be persisted
// to extension storage, and "show-phrase" is excluded from RESTORABLE_VIEWS
// so the popup can never reopen onto it.
const {
$,
showView,
showFlash,
flashCopyFeedback,
goBack,
onViewLeave,
pushCurrentView,
} = require("./helpers");
const { state } = require("../../shared/state");
const { decryptWithPassword } = require("../../shared/vault");
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
const VIEW = "show-phrase";
let walletIndex = null;
// Bumped by every clear(), which is what leaving the screen runs. reveal()
// captures it before awaiting the decrypt and refuses to touch the DOM if
// it has moved: a decrypt still in flight when the screen is left would
// otherwise write the phrase *after* the wipe, with nothing scheduled to
// wipe it again, leaving it in the hidden view for the life of the popup.
let revealGeneration = 0;
// True only if the reveal that captured `generation` is still the live one:
// the screen has not been left, cleared, or re-entered for another wallet
// since it started.
function isCurrentReveal(generation) {
return (
generation === revealGeneration &&
walletIndex !== null &&
state.currentView === VIEW
);
}
function fail(message) {
$("show-phrase-flash").textContent = message;
$("show-phrase-flash").style.visibility = "visible";
}
// Wipe every trace of the phrase and drop the wallet selection. Safe to
// call when nothing was ever revealed, and safe to call twice.
function clear() {
walletIndex = null;
revealGeneration += 1;
$("show-phrase-value").textContent = "";
$("show-phrase-password").value = "";
$("show-phrase-result").classList.add("hidden");
$("show-phrase-password-section").classList.remove("hidden");
$("show-phrase-flash").textContent = "";
$("show-phrase-flash").style.visibility = "hidden";
}
function show(walletIdx) {
const wallet = state.wallets[walletIdx];
if (!walletHasRecoveryPhrase(wallet)) {
showFlash("This wallet does not have a recovery phrase.");
return;
}
clear();
walletIndex = walletIdx;
$("show-phrase-wallet-name").textContent =
wallet.name || "Wallet " + (walletIdx + 1);
// Pushed here rather than by the caller: this function can return
// without navigating, and a push that happened anyway would leave an
// entry on the stack that no screen transition matches.
pushCurrentView();
showView(VIEW);
}
async function reveal() {
const password = $("show-phrase-password").value;
if (!password) {
fail("Please enter your password.");
return;
}
if (walletIndex === null) {
fail("No wallet is selected.");
return;
}
const wallet = state.wallets[walletIndex];
if (!walletHasRecoveryPhrase(wallet)) {
fail("This wallet does not have a recovery phrase.");
return;
}
const btn = $("btn-show-phrase-reveal");
btn.disabled = true;
btn.classList.add("text-muted");
const generation = revealGeneration;
try {
const phrase = await decryptWithPassword(
wallet.encryptedSecret,
password,
);
// The only suspension point in this view, and the only place a
// secret is written: if the screen was left while the decrypt ran,
// the wipe has already happened and this write must not land.
if (!isCurrentReveal(generation)) return;
$("show-phrase-password").value = "";
$("show-phrase-password-section").classList.add("hidden");
$("show-phrase-value").textContent = phrase;
$("show-phrase-result").classList.remove("hidden");
$("show-phrase-flash").textContent = "";
$("show-phrase-flash").style.visibility = "hidden";
} catch {
if (!isCurrentReveal(generation)) return;
// Deliberately not the caught error: the message is fixed so that
// nothing derived from the ciphertext or the attempt can surface.
fail("That password is not correct. Please try again.");
} finally {
btn.disabled = false;
btn.classList.remove("text-muted");
}
}
function init() {
onViewLeave(VIEW, clear);
$("btn-show-phrase-back").addEventListener("click", () => {
goBack();
});
$("btn-show-phrase-reveal").addEventListener("click", reveal);
$("show-phrase-value").addEventListener("click", () => {
const phrase = $("show-phrase-value").textContent;
if (!phrase) return;
navigator.clipboard.writeText(phrase);
showFlash("Copied!");
flashCopyFeedback($("show-phrase-value"));
});
}
module.exports = { init, show };

View File

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

114
src/shared/alarms.js Normal file
View File

@@ -0,0 +1,114 @@
// Periodic scheduling for the background context.
//
// The Chrome MV3 service worker is terminated after roughly 30 seconds idle,
// which takes every setInterval/setTimeout with it. The extension alarms API
// is the mechanism that survives: the browser holds the schedule and wakes
// the worker to deliver onAlarm. Firefox MV2 runs a persistent background
// page where timers would survive, but alarms behave identically there, so
// both targets share this path and both manifests declare the "alarms"
// permission.
//
// Periods are whole minutes at or above the browser-enforced one-minute
// minimum, so nothing here is silently clamped to a slower cadence.
//
// Trap for anyone changing a period: each job also carries a freshness guard
// that can veto its own scheduled tick. A guard timed to the alarm period
// halves the real cadence, because the guard is measured from when the last
// run finished and the alarm fires one run-duration earlier than that. Every
// guard must therefore either be strictly shorter than the period it gates or
// be bypassed on the scheduled tick — see backgroundRefresh() in
// src/background/index.js and updatePhishingList() in shared/phishingDomains.js.
const BALANCE_REFRESH_ALARM = "autistmask-balance-refresh";
const PHISHING_REFRESH_ALARM = "autistmask-phishing-refresh";
const MIN_ALARM_PERIOD_MINUTES = 1;
const BALANCE_REFRESH_PERIOD_MINUTES = 1;
const PHISHING_REFRESH_PERIOD_MINUTES = 24 * 60;
// Resolved on use rather than captured at module load: the worker is torn
// down and re-evaluated repeatedly, and tests install a stub after requiring
// the module.
function alarmsApi() {
if (typeof browser !== "undefined" && browser.alarms) return browser.alarms;
if (typeof chrome !== "undefined" && chrome.alarms) return chrome.alarms;
return null;
}
/**
* Create an alarm unless one with the requested period already exists.
*
* The existence check is load-bearing: creating an alarm resets its schedule,
* and this runs on every worker wake. Creating unconditionally would push the
* next fire time out on every incoming message, so a busy extension would
* never see the alarm fire at all.
*
* The period comparison is equally load-bearing in the other direction: an
* alarm created by an older version keeps its old period forever unless a
* changed constant re-creates it, so a period edit would never reach an
* existing install. Re-creating on a period change happens once and then
* settles into the existence check above.
*
* @param {string} name
* @param {number} periodInMinutes
* @returns {Promise<boolean>} true if the alarm was created by this call.
*/
async function ensureAlarm(name, periodInMinutes) {
const api = alarmsApi();
if (!api) return false;
const period = Math.max(periodInMinutes, MIN_ALARM_PERIOD_MINUTES);
const existing = await api.get(name);
if (existing && existing.periodInMinutes === period) return false;
api.create(name, {
periodInMinutes: period,
delayInMinutes: period,
});
return true;
}
/**
* Ensure both recurring background jobs are scheduled. Safe to call on every
* worker start, on onInstalled and on onStartup.
*
* @returns {Promise<{balance: boolean, phishing: boolean}>} which alarms this
* call had to create.
*/
async function ensureRecurringAlarms() {
const balance = await ensureAlarm(
BALANCE_REFRESH_ALARM,
BALANCE_REFRESH_PERIOD_MINUTES,
);
const phishing = await ensureAlarm(
PHISHING_REFRESH_ALARM,
PHISHING_REFRESH_PERIOD_MINUTES,
);
return { balance, phishing };
}
/**
* Register per-alarm handlers. One listener dispatches by alarm name so the
* worker only ever installs a single onAlarm listener.
*
* @param {Object<string, function>} handlers
* @returns {boolean} true if the listener was installed.
*/
function registerAlarmHandlers(handlers) {
const api = alarmsApi();
if (!api || !api.onAlarm) return false;
api.onAlarm.addListener((alarm) => {
const handler = handlers[alarm && alarm.name];
if (handler) handler();
});
return true;
}
module.exports = {
BALANCE_REFRESH_ALARM,
PHISHING_REFRESH_ALARM,
MIN_ALARM_PERIOD_MINUTES,
BALANCE_REFRESH_PERIOD_MINUTES,
PHISHING_REFRESH_PERIOD_MINUTES,
ensureAlarm,
ensureRecurringAlarms,
registerAlarmHandlers,
};

View File

@@ -0,0 +1,124 @@
// Verification of the signed artifacts produced by the approval popup.
//
// Signing happens in the popup, where the password is entered; the background
// only broadcasts the raw transaction and resolves the pending approval back
// to the requesting page. So that moving the signing out of the background
// does not turn the background into a blind relay, the background re-derives
// the signer from the artifact and checks it against the approval it is
// holding before acting on it. All recovery is delegated to ethers.
//
// Every failure message is a full sentence, because these strings are shown to
// the user and returned to the dApp.
const {
Transaction,
getAddress,
getBytes,
verifyMessage,
verifyTypedData,
} = require("ethers");
// Case-insensitive address comparison that tolerates absent values on either
// side. Two absent addresses compare equal (contract creation has no `to`).
function sameAddress(a, b) {
const aMissing = a === null || a === undefined || a === "";
const bMissing = b === null || b === undefined || b === "";
if (aMissing || bMissing) return aMissing && bMissing;
try {
return getAddress(a) === getAddress(b);
} catch {
return String(a).toLowerCase() === String(b).toLowerCase();
}
}
// Normalize a transaction value (hex string, decimal string, number or
// bigint) to a bigint. An absent value is zero, matching ethers.
function normalizeValue(v) {
if (v === null || v === undefined || v === "") return 0n;
return BigInt(v);
}
// Normalize call data to a lowercase hex string. Absent data is "0x".
function normalizeData(v) {
if (v === null || v === undefined || v === "" || v === "0x") return "0x";
return String(v).toLowerCase();
}
// Assert that a raw signed transaction is the transaction the user approved,
// signed by the address the approval was raised for. Returns the parsed
// ethers Transaction on success, throws otherwise.
function verifySignedTx(rawSignedTx, txParams, expectedFrom) {
if (typeof rawSignedTx !== "string" || !rawSignedTx.startsWith("0x")) {
throw new Error("The signed transaction is missing or malformed.");
}
let parsed;
try {
parsed = Transaction.from(rawSignedTx);
} catch {
throw new Error("The signed transaction could not be decoded.");
}
if (!parsed.from) {
throw new Error("The signed transaction carries no valid signature.");
}
if (!sameAddress(parsed.from, expectedFrom)) {
throw new Error(
"The signed transaction was signed by a different address than the one that was approved.",
);
}
if (!sameAddress(parsed.to, txParams.to)) {
throw new Error(
"The signed transaction does not go to the approved recipient.",
);
}
if (normalizeValue(parsed.value) !== normalizeValue(txParams.value)) {
throw new Error(
"The signed transaction does not carry the approved value.",
);
}
if (normalizeData(parsed.data) !== normalizeData(txParams.data)) {
throw new Error(
"The signed transaction does not carry the approved call data.",
);
}
return parsed;
}
// Assert that a signature over the approved message or typed data was
// produced by the address the approval was raised for. Returns the recovered
// address on success, throws otherwise.
function verifySignature(signParams, signature, expectedFrom) {
if (typeof signature !== "string" || !signature.startsWith("0x")) {
throw new Error("The signature is missing or malformed.");
}
let recovered;
try {
if (
signParams.method === "personal_sign" ||
signParams.method === "eth_sign"
) {
recovered = verifyMessage(getBytes(signParams.message), signature);
} else {
const typedData = JSON.parse(signParams.typedData);
const { domain, types, message } = typedData;
// ethers derives EIP712Domain itself and rejects it as an input.
delete types.EIP712Domain;
recovered = verifyTypedData(domain, types, message, signature);
}
} catch {
throw new Error("The signature could not be verified.");
}
if (!sameAddress(recovered, expectedFrom)) {
throw new Error(
"The signature was produced by a different address than the one that was approved.",
);
}
return recovered;
}
module.exports = { verifySignedTx, verifySignature, sameAddress };

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,6 +1,11 @@
// Cached ENS reverse resolution. // Cached ENS reverse resolution.
// Resolves addresses to ENS names via ethers provider.lookupAddress(), // Resolves addresses to ENS names via ethers provider.lookupAddress(),
// caching results in localStorage with a 12-hour TTL. // caching results in localStorage with a 12-hour TTL.
//
// POPUP ONLY. localStorage does not exist in the Chrome MV3 service worker,
// so this module must not be pulled into src/background/. Anything the
// background context needs to cache goes in extension storage instead (see
// shared/phishingDomains.js).
const { getProvider } = require("./balances"); const { getProvider } = require("./balances");
const { log } = require("./log"); const { log } = require("./log");

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

@@ -8,8 +8,14 @@
// The domain-checker checks the in-memory delta first (fresh/recent scam // The domain-checker checks the in-memory delta first (fresh/recent scam
// sites), then falls back to the vendored list. // sites), then falls back to the vendored list.
// //
// If the delta is under 256 KiB it is persisted to localStorage so it // If the delta and its fetch timestamp fit in 256 KiB they are persisted to
// survives extension/service-worker restarts. // extension storage, so they survive termination of the MV3 service worker.
// Extension storage, not localStorage: localStorage does not exist in a
// service worker, so the previous persistence never ran on Chrome at all.
// The stored timestamps are what keep a restarted worker from re-fetching on
// every wake while still noticing an overdue update. Those guards apply to the
// startup path only; the 24-hour alarm tick bypasses them, or it would veto
// its own refresh — see updatePhishingList().
const vendoredConfig = require("./phishingBlocklist.json"); const vendoredConfig = require("./phishingBlocklist.json");
@@ -17,7 +23,14 @@ const BLOCKLIST_URL =
"https://raw.githubusercontent.com/MetaMask/eth-phishing-detect/main/src/config.json"; "https://raw.githubusercontent.com/MetaMask/eth-phishing-detect/main/src/config.json";
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
const REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
// Floor on how often an unscheduled path may hit the network. The worker is
// revived every ~30 seconds while the browser is busy, and every revival runs
// the startup path; without a persisted record of the last attempt, any state
// that leaves lastFetchTime unset — a fetch that failed, or a delta too large
// to store — would download the full list on every single wake.
const MIN_FETCH_ATTEMPT_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
const DELTA_STORAGE_KEY = "phishing-delta"; const DELTA_STORAGE_KEY = "phishing-delta";
const MAX_DELTA_BYTES = 256 * 1024; // 256 KiB const MAX_DELTA_BYTES = 256 * 1024; // 256 KiB
@@ -29,45 +42,104 @@ const vendoredBlacklist = new Set(
// Delta set — only entries from live list that are NOT in vendored. // Delta set — only entries from live list that are NOT in vendored.
let deltaBlacklist = new Set(); let deltaBlacklist = new Set();
let lastFetchTime = 0; let lastFetchTime = 0;
let lastAttemptTime = 0;
let fetchPromise = null; let fetchPromise = null;
let refreshTimer = null; let loadPromise = null;
// Resolved on use rather than captured at module load, so a test can install
// a stub after requiring the module and so the popup — which has no reason to
// touch the delta — does not fail to load where the API is absent.
function storageApi() {
if (typeof browser !== "undefined" && browser.storage) {
return browser.storage.local;
}
if (typeof chrome !== "undefined" && chrome.storage) {
return chrome.storage.local;
}
return null;
}
/** /**
* Load delta entries from localStorage on startup. * Sanitise a timestamp read back from storage.
* Called once during module initialization in the background script. *
* A value in the future is permanent poison: every guard here measures elapsed
* time as `Date.now() - stamp` and tests only the lower bound, so a stamp a
* year ahead suppresses updates for a year with no path that ever clears it.
* Clock skew and a restored profile backup both produce one. Since these
* timestamps only ever gate work, discarding an impossible one is safe: it
* costs at most a single extra fetch and restores a sane value immediately.
*
* @param {unknown} value
* @returns {number} the timestamp, or 0 if it is unusable.
*/ */
function loadDeltaFromStorage() { function sanitizeTimestamp(value) {
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
if (value <= 0 || value > Date.now()) return 0;
return value;
}
/**
* Load the persisted delta and its timestamps from extension storage.
* Runs once per worker lifetime; every entry point funnels through
* ensureDeltaLoaded() so a wake from termination restores state exactly once.
*
* @returns {Promise<void>}
*/
async function loadDeltaFromStorage() {
const storage = storageApi();
if (!storage) return;
try { try {
const raw = localStorage.getItem(DELTA_STORAGE_KEY); const result = await storage.get(DELTA_STORAGE_KEY);
if (!raw) return; const data = result && result[DELTA_STORAGE_KEY];
const data = JSON.parse(raw); if (!data) return;
if (data.blacklist && Array.isArray(data.blacklist)) { if (Array.isArray(data.blacklist)) {
deltaBlacklist = new Set( deltaBlacklist = new Set(
data.blacklist.map((d) => d.toLowerCase()), data.blacklist.map((d) => d.toLowerCase()),
); );
} }
lastFetchTime = sanitizeTimestamp(data.lastFetchTime);
lastAttemptTime = sanitizeTimestamp(data.lastAttemptTime);
} catch { } catch {
// localStorage unavailable or corrupt — start empty // Storage unavailable or corrupt — start empty and re-fetch.
} }
} }
function ensureDeltaLoaded() {
if (!loadPromise) loadPromise = loadDeltaFromStorage();
return loadPromise;
}
/** /**
* Persist delta to localStorage if it fits within MAX_DELTA_BYTES. * Persist the delta and its timestamps if they fit within MAX_DELTA_BYTES.
*
* The 256 KiB cap covers the delta and its freshness claim: when the delta is
* too large to keep, lastFetchTime goes with it, so the next start re-fetches
* rather than trusting a freshness claim for a delta it no longer holds.
* lastAttemptTime is written either way — it records that the network was
* contacted, which stays true whatever became of the response, and it is what
* stops a permanently oversized list from downloading on every worker wake.
*
* @returns {Promise<void>}
*/ */
function saveDeltaToStorage() { async function saveDeltaToStorage() {
const storage = storageApi();
if (!storage) return;
try { try {
const data = { const data = {
blacklist: Array.from(deltaBlacklist), blacklist: Array.from(deltaBlacklist),
lastFetchTime,
lastAttemptTime,
}; };
const json = JSON.stringify(data); const json = JSON.stringify(data);
if (json.length < MAX_DELTA_BYTES) { if (json.length < MAX_DELTA_BYTES) {
localStorage.setItem(DELTA_STORAGE_KEY, json); await storage.set({ [DELTA_STORAGE_KEY]: data });
} else if (lastAttemptTime > 0) {
await storage.set({ [DELTA_STORAGE_KEY]: { lastAttemptTime } });
} else { } else {
// Too large — remove stale key if present await storage.remove(DELTA_STORAGE_KEY);
localStorage.removeItem(DELTA_STORAGE_KEY);
} }
} catch { } catch {
// localStorage unavailable — skip silently // Storage unavailable — skip silently
} }
} }
@@ -76,6 +148,7 @@ function saveDeltaToStorage() {
* Used for both live fetches and testing. * Used for both live fetches and testing.
* *
* @param {{ blacklist?: string[] }} config * @param {{ blacklist?: string[] }} config
* @returns {Promise<void>} resolves once the delta has been persisted.
*/ */
function loadConfig(config) { function loadConfig(config) {
const liveBlacklist = (config.blacklist || []).map((d) => d.toLowerCase()); const liveBlacklist = (config.blacklist || []).map((d) => d.toLowerCase());
@@ -86,7 +159,7 @@ function loadConfig(config) {
); );
lastFetchTime = Date.now(); lastFetchTime = Date.now();
saveDeltaToStorage(); return saveDeltaToStorage();
} }
/** /**
@@ -111,6 +184,11 @@ function hostnameVariants(hostname) {
* Check if a hostname is on the phishing blocklist. * Check if a hostname is on the phishing blocklist.
* Checks delta first (fresh/recent scam sites), then vendored list. * Checks delta first (fresh/recent scam sites), then vendored list.
* *
* Synchronous by design — callers answer an approval prompt with it. On a
* worker that has just woken, the persisted delta may still be loading; the
* vendored list, which is bundled and always present, carries the check until
* it lands.
*
* @param {string} hostname - The hostname to check. * @param {string} hostname - The hostname to check.
* @returns {boolean} * @returns {boolean}
*/ */
@@ -127,28 +205,59 @@ function isPhishingDomain(hostname) {
/** /**
* Fetch the latest blocklist and compute delta against vendored data. * Fetch the latest blocklist and compute delta against vendored data.
* De-duplicates concurrent fetches. Results are cached for CACHE_TTL_MS. * De-duplicates concurrent fetches. Results are cached for CACHE_TTL_MS,
* counted from the persisted timestamp so the cache outlives the worker.
* *
* `force` is what makes the 24-hour alarm actually refresh every 24 hours.
* The alarm fires one period after the previous alarm, but lastFetchTime is
* stamped when that fetch *completed*, so an unforced tick lands one fetch
* latency inside its own TTL, skips, and turns the real cadence into 48 hours.
* Shortening the TTL instead would not fix it: the worker wakes every ~30
* seconds and the startup path re-checks the TTL each time, so a shortened TTL
* simply becomes the real cadence. The TTL is there to stop redundant fetches
* on wake, and the scheduled tick is not redundant, so it bypasses it.
*
* @param {{force?: boolean}} [opts] force: fetch unless one is already in
* flight, ignoring both the freshness and the retry guard. For the scheduled
* alarm tick only.
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async function updatePhishingList() { async function updatePhishingList({ force = false } = {}) {
// Skip if recently fetched // A worker that has just been revived knows nothing until the persisted
if (Date.now() - lastFetchTime < CACHE_TTL_MS && lastFetchTime > 0) { // record is back in memory; without this the freshness check below would
// always see 0 and re-fetch on every wake.
await ensureDeltaLoaded();
if (!force) {
const now = Date.now();
// Skip if recently fetched.
if (lastFetchTime > 0 && now - lastFetchTime < CACHE_TTL_MS) return;
// Skip if the network was contacted recently and the result was not
// usable — a failed fetch or an oversized delta leaves lastFetchTime
// unset, and without this every wake would retry.
if (
lastAttemptTime > 0 &&
now - lastAttemptTime < MIN_FETCH_ATTEMPT_INTERVAL_MS
) {
return; return;
} }
}
// De-duplicate concurrent calls // De-duplicate concurrent calls
if (fetchPromise) return fetchPromise; if (fetchPromise) return fetchPromise;
fetchPromise = (async () => { fetchPromise = (async () => {
lastAttemptTime = Date.now();
try { try {
const resp = await fetch(BLOCKLIST_URL); const resp = await fetch(BLOCKLIST_URL);
if (!resp.ok) throw new Error("HTTP " + resp.status); if (!resp.ok) throw new Error("HTTP " + resp.status);
const config = await resp.json(); const config = await resp.json();
loadConfig(config); await loadConfig(config);
} catch { } catch {
// Silently fail — vendored list still provides coverage. // Silently fail — vendored list still provides coverage. Persist
// We'll retry next time. // the attempt so a persistently failing fetch is retried on the
// schedule rather than on every wake.
await saveDeltaToStorage();
} finally { } finally {
fetchPromise = null; fetchPromise = null;
} }
@@ -158,12 +267,29 @@ async function updatePhishingList() {
} }
/** /**
* Start periodic refresh of the phishing list. * Restore persisted state and fetch if the list is overdue.
* Should be called once from the background script on startup. *
* Called from the background script every time it starts — a fresh install,
* a browser start, and every revival of a terminated service worker all land
* here. The recurring 24-hour schedule itself is an alarm (see
* shared/alarms.js), not a timer, because timers die with the worker.
*
* @returns {Promise<void>}
*/ */
function startPeriodicRefresh() { async function initPhishingList() {
if (refreshTimer) return; await ensureDeltaLoaded();
refreshTimer = setInterval(updatePhishingList, REFRESH_INTERVAL_MS); return updatePhishingList();
}
/**
* The 24-hour alarm tick. Separate from initPhishingList() because this is the
* scheduled refresh and must not be vetoed by the guards that exist to keep
* the unscheduled startup path off the network.
*
* @returns {Promise<void>}
*/
async function refreshPhishingListOnSchedule() {
return updatePhishingList({ force: true });
} }
/** /**
@@ -190,21 +316,22 @@ function getDeltaSize() {
function _reset() { function _reset() {
deltaBlacklist = new Set(); deltaBlacklist = new Set();
lastFetchTime = 0; lastFetchTime = 0;
lastAttemptTime = 0;
fetchPromise = null; fetchPromise = null;
if (refreshTimer) { loadPromise = null;
clearInterval(refreshTimer);
refreshTimer = null;
}
} }
// Load persisted delta on module initialization
loadDeltaFromStorage();
module.exports = { module.exports = {
isPhishingDomain, isPhishingDomain,
updatePhishingList, updatePhishingList,
startPeriodicRefresh, refreshPhishingListOnSchedule,
initPhishingList,
loadDeltaFromStorage,
loadConfig, loadConfig,
CACHE_TTL_MS,
MIN_FETCH_ATTEMPT_INTERVAL_MS,
DELTA_STORAGE_KEY,
MAX_DELTA_BYTES,
getBlocklistSize, getBlocklistSize,
getDeltaSize, getDeltaSize,
hostnameVariants, hostnameVariants,

View File

@@ -21,6 +21,7 @@ const DEFAULT_STATE = {
deniedSites: {}, deniedSites: {},
rememberSiteChoice: true, rememberSiteChoice: true,
showZeroBalanceTokens: true, showZeroBalanceTokens: true,
hideSpoofedSymbols: true,
hideLowHolderTokens: true, hideLowHolderTokens: true,
hideFraudContracts: true, hideFraudContracts: true,
hideDustTransactions: true, hideDustTransactions: true,
@@ -29,6 +30,7 @@ const DEFAULT_STATE = {
fraudContracts: [], fraudContracts: [],
tokenHolderCache: {}, tokenHolderCache: {},
theme: "system", theme: "system",
debugMode: false,
}; };
const state = { const state = {
@@ -60,6 +62,7 @@ async function saveState() {
deniedSites: state.deniedSites, deniedSites: state.deniedSites,
rememberSiteChoice: state.rememberSiteChoice, rememberSiteChoice: state.rememberSiteChoice,
showZeroBalanceTokens: state.showZeroBalanceTokens, showZeroBalanceTokens: state.showZeroBalanceTokens,
hideSpoofedSymbols: state.hideSpoofedSymbols,
hideLowHolderTokens: state.hideLowHolderTokens, hideLowHolderTokens: state.hideLowHolderTokens,
hideFraudContracts: state.hideFraudContracts, hideFraudContracts: state.hideFraudContracts,
hideDustTransactions: state.hideDustTransactions, hideDustTransactions: state.hideDustTransactions,
@@ -68,6 +71,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,
@@ -82,8 +86,11 @@ async function loadState() {
const result = await storageApi.get("autistmask"); const result = await storageApi.get("autistmask");
if (result.autistmask) { if (result.autistmask) {
const saved = result.autistmask; const saved = result.autistmask;
state.hasWallet = saved.hasWallet;
state.wallets = saved.wallets || []; state.wallets = saved.wallets || [];
// Derived, never read from storage: a profile persisted with the flag
// out of step with the wallet list would otherwise stay broken on
// every load. Nothing depends on the two disagreeing.
state.hasWallet = state.wallets.length > 0;
state.trackedTokens = saved.trackedTokens || []; state.trackedTokens = saved.trackedTokens || [];
state.networkId = saved.networkId || DEFAULT_STATE.networkId; state.networkId = saved.networkId || DEFAULT_STATE.networkId;
state.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl; state.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl;
@@ -107,6 +114,12 @@ async function loadState() {
saved.showZeroBalanceTokens !== undefined saved.showZeroBalanceTokens !== undefined
? saved.showZeroBalanceTokens ? saved.showZeroBalanceTokens
: true; : true;
// A profile written before this setting existed has no key for it.
// It is a safety filter, so absent must load as on, not as undefined.
state.hideSpoofedSymbols =
saved.hideSpoofedSymbols !== undefined
? saved.hideSpoofedSymbols
: true;
state.hideLowHolderTokens = state.hideLowHolderTokens =
saved.hideLowHolderTokens !== undefined saved.hideLowHolderTokens !== undefined
? saved.hideLowHolderTokens ? saved.hideLowHolderTokens
@@ -128,6 +141,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

@@ -10,6 +10,14 @@ const { formatEther, formatUnits } = require("ethers");
const { log, debugFetch } = require("./log"); const { log, debugFetch } = require("./log");
const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList"); const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList");
// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
// over the address, not part of its identity. Every address comparison in
// this file goes through this helper, so an address arriving in checksummed
// or upper-case form can never be read as a different address.
function normalizeAddress(addr) {
return (addr || "").toLowerCase();
}
function formatTxValue(val) { function formatTxValue(val) {
const parts = val.split("."); const parts = val.split(".");
if (parts.length === 1) return val + ".0000"; if (parts.length === 1) return val + ".0000";
@@ -30,10 +38,10 @@ function parseTx(tx, addrLower) {
let exactValue = formatEther(rawWei); let exactValue = formatEther(rawWei);
let rawAmount = rawWei; let rawAmount = rawWei;
let rawUnit = "wei"; let rawUnit = "wei";
let direction = from.toLowerCase() === addrLower ? "sent" : "received"; let direction = normalizeAddress(from) === addrLower ? "sent" : "received";
let directionLabel = direction === "sent" ? "Sent" : "Received"; let directionLabel = direction === "sent" ? "Sent" : "Received";
if (toIsContract && method && method !== "transfer") { if (toIsContract && method && method !== "transfer") {
const token = TOKEN_BY_ADDRESS.get(to.toLowerCase()); const token = TOKEN_BY_ADDRESS.get(normalizeAddress(to));
if (token) { if (token) {
symbol = token.symbol; symbol = token.symbol;
} }
@@ -87,7 +95,8 @@ function parseTokenTransfer(tt, addrLower) {
const to = tt.to?.hash || ""; const to = tt.to?.hash || "";
const decimals = parseInt(tt.total?.decimals || "18", 10); const decimals = parseInt(tt.total?.decimals || "18", 10);
const rawVal = tt.total?.value || "0"; const rawVal = tt.total?.value || "0";
const direction = from.toLowerCase() === addrLower ? "sent" : "received"; const direction =
normalizeAddress(from) === addrLower ? "sent" : "received";
const sym = tt.token?.symbol || "?"; const sym = tt.token?.symbol || "?";
return { return {
hash: tt.transaction_hash, hash: tt.transaction_hash,
@@ -104,18 +113,95 @@ function parseTokenTransfer(tt, addrLower) {
direction: direction, direction: direction,
directionLabel: direction === "sent" ? "Sent" : "Received", directionLabel: direction === "sent" ? "Sent" : "Received",
isError: false, isError: false,
contractAddress: ( contractAddress: normalizeAddress(
tt.token?.address_hash || tt.token?.address_hash || tt.token?.address || "",
tt.token?.address || ),
""
).toLowerCase(),
holders: parseInt(tt.token?.holders_count || "0", 10), holders: parseInt(tt.token?.holders_count || "0", 10),
}; };
} }
// True when a parsed native entry moved no ETH. Contract-call entries have
// their amount fields blanked by parseTx, so they are never judged here.
function movedNoEther(tx) {
if (tx.direction === "contract") return false;
return BigInt(tx.rawAmount || "0") === BigInt(0);
}
// Merge parsed normal transactions with parsed ERC-20 token transfers into
// one row per distinct value movement. Pure: it reads only its arguments
// and returns a new list sorted newest block first.
//
// The merge key is the transaction hash for the native entry and
// hash + token contract for each token transfer, so:
//
// - A display-level contract call (a swap and friends, direction
// "contract") absorbs every token leg of its hash into the single
// native entry, because the legs are hops of one operation rather
// than separate movements the user made.
// - Otherwise each distinct token contract in the transaction keeps its
// own row, so a hash carrying several genuine transfers stays several
// rows.
// - The native entry of such a transaction is dropped when it moved no
// ETH and at least one token transfer shares its hash: that entry is
// the ERC-20 call itself, already represented by the token row. A
// native entry that moved ETH survives alongside the token rows, since
// the ETH and the tokens are two real movements, and a zero-value
// native transaction with no token transfer on its hash survives too.
function mergeTransactions(txs, tokenTransfers) {
const byKey = new Map();
// Entries are copied so consolidation never writes through to the
// caller's objects.
for (const tx of txs) {
byKey.set(tx.hash, { ...tx });
}
const absorbedHashes = new Set();
for (const parsed of tokenTransfers) {
const existing = byKey.get(parsed.hash);
if (existing && existing.direction === "contract") {
// For contract calls (swaps), consolidate into the original
// tx entry. Prefer the "received" transfer (swap output)
// for the display amount. If no received transfer exists,
// fall back to the first "sent" transfer (swap input).
const isReceived = parsed.direction === "received";
const needsAmount = !existing.exactValue;
if (isReceived || needsAmount) {
existing.value = parsed.value;
existing.exactValue = parsed.exactValue;
existing.rawAmount = parsed.rawAmount;
existing.rawUnit = parsed.rawUnit;
existing.symbol = parsed.symbol;
existing.contractAddress = parsed.contractAddress;
existing.holders = parsed.holders;
}
// Keep the original tx's from/to (the user's address and the
// contract they called), not the token transfer's from/to
// which may be a router or Permit2 contract.
continue;
}
if (existing && movedNoEther(existing)) {
absorbedHashes.add(parsed.hash);
}
// Every other token transfer gets its own entry.
byKey.set(parsed.hash + ":" + (parsed.contractAddress || ""), {
...parsed,
});
}
for (const hash of absorbedHashes) {
byKey.delete(hash);
}
const merged = [...byKey.values()];
merged.sort((a, b) => b.blockNumber - a.blockNumber);
return merged;
}
async function fetchRecentTransactions(address, blockscoutUrl, count = 25) { async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
log.debugf("fetchRecentTransactions", address); log.debugf("fetchRecentTransactions", address);
const addrLower = address.toLowerCase(); const addrLower = normalizeAddress(address);
const [txResp, ttResp] = await Promise.all([ const [txResp, ttResp] = await Promise.all([
debugFetch(blockscoutUrl + "/addresses/" + address + "/transactions"), debugFetch(blockscoutUrl + "/addresses/" + address + "/transactions"),
@@ -145,53 +231,11 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
const txJson = txResp.ok ? await txResp.json() : {}; const txJson = txResp.ok ? await txResp.json() : {};
const ttJson = ttResp.ok ? await ttResp.json() : {}; const ttJson = ttResp.ok ? await ttResp.json() : {};
const txsByHash = new Map(); const txs = mergeTransactions(
(txJson.items || []).map((tx) => parseTx(tx, addrLower)),
(ttJson.items || []).map((tt) => parseTokenTransfer(tt, addrLower)),
);
for (const tx of txJson.items || []) {
txsByHash.set(tx.hash, parseTx(tx, addrLower));
}
// When a token transfer shares a hash with a normal tx, the normal tx
// is the contract call (0 ETH) and the token transfer has the real
// amount and symbol. For contract calls (swaps), a single transaction
// can produce multiple token transfers (input, intermediates, output).
// We consolidate these into the original tx entry using the token
// transfer where the user *receives* tokens (the swap output), so
// the transaction list shows the final result rather than confusing
// intermediate hops. We preserve the original tx's from/to so the
// user sees their own address, not a router or Permit2 contract.
for (const tt of ttJson.items || []) {
const parsed = parseTokenTransfer(tt, addrLower);
const existing = txsByHash.get(parsed.hash);
if (existing && existing.direction === "contract") {
// For contract calls (swaps), consolidate into the original
// tx entry. Prefer the "received" transfer (swap output)
// for the display amount. If no received transfer exists,
// fall back to the first "sent" transfer (swap input).
const isReceived = parsed.direction === "received";
const needsAmount = !existing.exactValue;
if (isReceived || needsAmount) {
existing.value = parsed.value;
existing.exactValue = parsed.exactValue;
existing.rawAmount = parsed.rawAmount;
existing.rawUnit = parsed.rawUnit;
existing.symbol = parsed.symbol;
existing.contractAddress = parsed.contractAddress;
existing.holders = parsed.holders;
}
// Keep the original tx's from/to (the user's address and the
// contract they called), not the token transfer's from/to
// which may be a router or Permit2 contract.
continue;
}
// Non-contract token transfers get their own entries.
const ttKey = parsed.hash + ":" + (parsed.contractAddress || "");
txsByHash.set(ttKey, parsed);
}
const txs = [...txsByHash.values()];
txs.sort((a, b) => b.blockNumber - a.blockNumber);
const result = txs.slice(0, count); const result = txs.slice(0, count);
log.debugf("fetchRecentTransactions done, count:", result.length); log.debugf("fetchRecentTransactions done, count:", result.length);
return result; return result;
@@ -206,34 +250,45 @@ function isSpoofedSymbol(tx) {
if (!KNOWN_SYMBOLS.has(symbol)) return false; if (!KNOWN_SYMBOLS.has(symbol)) return false;
const legit = KNOWN_SYMBOLS.get(symbol); const legit = KNOWN_SYMBOLS.get(symbol);
if (legit === null) return true; // "ETH" as ERC-20 is always fake if (legit === null) return true; // "ETH" as ERC-20 is always fake
return tx.contractAddress !== legit; return normalizeAddress(tx.contractAddress) !== normalizeAddress(legit);
} }
// Pure filter function. Takes raw transactions and filter settings, // Pure filter function. Takes raw transactions and filter settings,
// returns { transactions, newFraudContracts }. // returns { transactions, newFraudContracts }.
function filterTransactions(txs, filters = {}) { function filterTransactions(txs, filters = {}) {
const fraudSet = new Set( const fraudSet = new Set(
(filters.fraudContracts || []).map((a) => a.toLowerCase()), (filters.fraudContracts || []).map(normalizeAddress),
); );
// The dust threshold defaults only when it is unset (nullish): a
// threshold of 0 is a real value meaning "hide nothing", since no
// transaction has a value below 0 gwei. It is therefore equivalent to
// clearing the hide-dust checkbox, and the two controls cannot override
// each other in either direction.
const dustThresholdGwei = filters.dustThresholdGwei ?? 100000;
const newFraud = []; const newFraud = [];
const filtered = []; const filtered = [];
// Fail-safe, unlike the three flags below: this one is off only when the
// caller says so explicitly, so a caller that omits the key keeps the
// check rather than silently losing it. The setting also governs the
// blocklist learning below, which exists only to serve this check —
// leaving learning on while the check is off would re-hide the very rows
// the user asked to see, through the fraud-contract rule.
const hideSpoofed = filters.hideSpoofedSymbols !== false;
for (const tx of txs) { for (const tx of txs) {
// Always filter spoofed known symbols and record the fraud contract const contract = normalizeAddress(tx.contractAddress);
if (isSpoofedSymbol(tx)) {
if (tx.contractAddress && !fraudSet.has(tx.contractAddress)) { // Filter spoofed known symbols and record the fraud contract
fraudSet.add(tx.contractAddress); if (hideSpoofed && isSpoofedSymbol(tx)) {
newFraud.push(tx.contractAddress); if (contract && !fraudSet.has(contract)) {
fraudSet.add(contract);
newFraud.push(contract);
} }
continue; continue;
} }
// Filter fraud contracts if setting is on // Filter fraud contracts if setting is on
if ( if (filters.hideFraudContracts && contract && fraudSet.has(contract)) {
filters.hideFraudContracts &&
tx.contractAddress &&
fraudSet.has(tx.contractAddress)
) {
continue; continue;
} }
@@ -254,7 +309,7 @@ function filterTransactions(txs, filters = {}) {
filters.hideDustTransactions && filters.hideDustTransactions &&
!tx.isContractCall && !tx.isContractCall &&
tx.valueGwei !== null && tx.valueGwei !== null &&
tx.valueGwei < (filters.dustThresholdGwei || 100000) tx.valueGwei < dustThresholdGwei
) { ) {
continue; continue;
} }
@@ -265,4 +320,8 @@ function filterTransactions(txs, filters = {}) {
return { transactions: filtered, newFraudContracts: newFraud }; return { transactions: filtered, newFraudContracts: newFraud };
} }
module.exports = { fetchRecentTransactions, filterTransactions }; module.exports = {
fetchRecentTransactions,
filterTransactions,
mergeTransactions,
};

171
src/shared/txValidation.js Normal file
View File

@@ -0,0 +1,171 @@
// Balance arithmetic for the transaction confirmation screen.
//
// Pure: no DOM, no network, no state. Everything is exact integer math on
// 18-decimal fixed point (wei for ETH), so it can be unit tested directly
// instead of through the confirmation view. The caller maps the returned
// codes to the reserved message elements on the screen.
//
// Human decimal strings ("1.25") are scaled to 18 decimals for comparison.
// That scale is independent of a token's own decimals: both the amount and
// the token balance arrive as human decimal strings, so comparing them at a
// common scale is exact.
const { parseUnits } = require("ethers");
const SCALE_DECIMALS = 18;
// Whether the asynchronous fee estimate has arrived yet.
const FEE_PENDING = "pending";
const FEE_KNOWN = "known";
const FEE_UNAVAILABLE = "unavailable";
const CODES = {
// The amount is not a non-negative number we can do exact arithmetic on.
AMOUNT_INVALID: "amount-invalid",
// ERC-20: the token amount exceeds the token balance.
INSUFFICIENT_TOKEN: "insufficient-token",
// ETH: the amount alone already exceeds the ETH balance.
INSUFFICIENT_ETH: "insufficient-eth",
// ETH: the amount fits, the amount plus the network fee does not.
INSUFFICIENT_ETH_WITH_FEE: "insufficient-eth-with-fee",
// ERC-20: the token balance covers the transfer, the ETH balance does
// not cover the network fee it costs.
INSUFFICIENT_ETH_FOR_FEE: "insufficient-eth-for-fee",
// The fee estimate has not arrived yet.
FEE_PENDING: "fee-pending",
// The fee estimate failed. Unknown is never treated as zero.
FEE_UNAVAILABLE: "fee-unavailable",
};
// The fee that must be reserved for a transaction, in wei: the amount the
// node will require, not the amount the transaction is expected to cost.
//
// A send that pins no fee fields is populated by ethers as a type-2
// (EIP-1559) transaction, and a node validates that against
// `value + gasLimit * maxFeePerGas`. ethers derives maxFeePerGas as
// `baseFeePerGas * 2 + maxPriorityFeePerGas`, so reserving `gasPrice`
// (roughly `baseFee + tip`) under-reserves by about `gasLimit * baseFee` and
// lets through a transaction the node then rejects with "insufficient funds
// for gas * price + value". gasPrice is the fallback only for a network that
// offers no type-2 pricing at all.
//
// Returns null when no usable price is available, which the caller must treat
// as a failed estimate rather than as a free transaction.
function feeReserveWei(gasLimit, feeData) {
if (typeof gasLimit !== "bigint" || gasLimit < 0n) return null;
const price = feeData?.maxFeePerGas ?? feeData?.gasPrice;
if (typeof price !== "bigint" || price < 0n) return null;
return gasLimit * price;
}
// What the transaction is expected to actually cost, in wei — not what must
// be reserved for it. A type-2 transaction is charged `baseFee + tip` per gas
// and refunded the rest of the cap, and `eth_gasPrice` reports roughly that,
// so gasPrice is the estimate and maxFeePerGas is the reserve. On a network
// with no type-2 pricing the two are the same number.
//
// Display only: nothing gates on this. Returns null on the same unusable
// inputs as feeReserveWei().
function feeEstimateWei(gasLimit, feeData) {
if (typeof gasLimit !== "bigint" || gasLimit < 0n) return null;
const price = feeData?.gasPrice ?? feeData?.maxFeePerGas;
if (typeof price !== "bigint" || price < 0n) return null;
return gasLimit * price;
}
// Scale a human decimal string to 18-decimal fixed point. Returns null when
// the value is not a decimal number or carries more precision than the scale
// can hold, which the caller must treat as unusable rather than as zero.
function toFixedPoint(value) {
if (typeof value !== "string" && typeof value !== "number") return null;
const text = String(value).trim();
if (text === "") return null;
try {
return parseUnits(text, SCALE_DECIMALS);
} catch (e) {
return null;
}
}
// Validate a pending transfer against the balances that must cover it.
//
// isErc20 — token transfer rather than a native ETH transfer
// amount — human decimal string being sent, non-negative. Anything
// else, a negative value included, is an unusable amount
// rather than an amount that passes every comparison.
// ethBalance — human decimal string, the sender's ETH balance
// tokenBalance — human decimal string, the sender's token balance
// feeStatus — FEE_PENDING, FEE_KNOWN or FEE_UNAVAILABLE. Anything else
// is treated as FEE_UNAVAILABLE.
// feeWei — the fee reserve in wei from feeReserveWei(), as a
// non-negative bigint, when FEE_KNOWN. Any other value makes
// the fee unavailable rather than zero.
//
// Returns { canSend, codes }. Every code blocks sending: canSend is true
// only when nothing was found.
function validateTransfer({
isErc20 = false,
amount,
ethBalance,
tokenBalance,
feeStatus = FEE_PENDING,
feeWei = null,
} = {}) {
const codes = [];
const amountFp = toFixedPoint(amount);
const ethFp = toFixedPoint(ethBalance) ?? 0n;
// A negative amount parses to a valid bigint, so every comparison below
// is trivially false and the send clears the screen — then dies at encode
// time in parseEther(). Unusable, on the same footing as a malformed fee.
if (amountFp === null || amountFp < 0n) {
codes.push(CODES.AMOUNT_INVALID);
return { canSend: false, codes };
}
// Fail closed. Anything that is not a usable fee under a recognised
// status — a malformed feeWei, or a status this module does not know —
// is an unavailable estimate, never a fee of zero. Every such input errs
// in the direction that lets money out, so none of them is trusted.
const known =
feeStatus === FEE_KNOWN && typeof feeWei === "bigint" && feeWei >= 0n;
let status = feeStatus;
if (feeStatus === FEE_KNOWN && !known) status = FEE_UNAVAILABLE;
if (status !== FEE_KNOWN && status !== FEE_PENDING) {
status = FEE_UNAVAILABLE;
}
const feeFp = known ? feeWei : null;
if (isErc20) {
const tokenFp = toFixedPoint(tokenBalance) ?? 0n;
if (amountFp > tokenFp) codes.push(CODES.INSUFFICIENT_TOKEN);
if (feeFp !== null && feeFp > ethFp) {
codes.push(CODES.INSUFFICIENT_ETH_FOR_FEE);
}
} else if (amountFp > ethFp) {
codes.push(CODES.INSUFFICIENT_ETH);
} else if (feeFp !== null && amountFp + feeFp > ethFp) {
codes.push(CODES.INSUFFICIENT_ETH_WITH_FEE);
}
// An unknown fee is never assumed to be zero: sending stays blocked
// until the estimate arrives, and stays blocked if it never does.
if (status === FEE_PENDING) codes.push(CODES.FEE_PENDING);
if (status === FEE_UNAVAILABLE) codes.push(CODES.FEE_UNAVAILABLE);
return { canSend: codes.length === 0, codes };
}
module.exports = {
CODES,
FEE_PENDING,
FEE_KNOWN,
FEE_UNAVAILABLE,
SCALE_DECIMALS,
feeReserveWei,
feeEstimateWei,
toFixedPoint,
validateTransfer,
};

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)),
@@ -12,8 +16,60 @@ function generateMnemonic() {
return m.phrase; return m.phrase;
} }
// Every extended key (xprv or xpub) entering the app goes through this.
//
// ethers' HDNodeWallet.fromExtendedKey does NOT verify the base58 checksum
// when the decoded payload is the usual 82 bytes, which is exactly the case
// the checksum exists to catch: a key with a one-character typo parses into a
// *different* wallet instead of being rejected. Re-encoding the parsed node
// reproduces a well-formed key byte for byte, checksum included, so comparing
// the round trip against the input rejects any altered character. Measured by
// the sweep in tests/wallet.test.js over every single-character substitution
// of the BIP-32 vector 1 master key: 199 parse without the round-trip
// comparison, 0 with it.
//
// Returns the parsed node, or null if the key is not a well-formed extended
// key. Callers turn null into a user-facing error; none of them may fall back
// to fromExtendedKey directly.
function parseExtendedKey(key) {
if (typeof key !== "string") return null;
try {
const node = HDNodeWallet.fromExtendedKey(key);
return node.extendedKey === key ? node : null;
} catch {
return null;
}
}
// A master key is at depth 0. Only from there is BIP44_ETH_PATH the absolute
// path it names; deriving it under an account-level or child key yields
// addresses that correspond to nothing the user holds.
const MASTER_DEPTH = 0;
// Parse an extended private key that the BIP-44 Ethereum account path can be
// derived from, or throw. Both callers derive BIP44_ETH_PATH from the result.
function masterXprvOrThrow(key) {
const node = parseExtendedKey(key);
if (!node) {
throw new Error("Not a valid extended private key (xprv).");
}
if (!node.privateKey) {
throw new Error("Not an extended private key (xprv).");
}
if (node.depth !== MASTER_DEPTH) {
throw new Error(
"Not a master extended private key (xprv): an account-level or " +
"child key cannot be imported.",
);
}
return node;
}
function deriveAddressFromXpub(xpub, index) { function deriveAddressFromXpub(xpub, index) {
const node = HDNodeWallet.fromExtendedKey(xpub); const node = parseExtendedKey(xpub);
if (!node) {
throw new Error("Not a valid extended key.");
}
return node.deriveChild(index).address; return node.deriveChild(index).address;
} }
@@ -25,23 +81,28 @@ function hdWalletFromMnemonic(mnemonic) {
} }
function hdWalletFromXprv(xprv) { function hdWalletFromXprv(xprv) {
const root = HDNodeWallet.fromExtendedKey(xprv); // BIP44_ETH_PATH is absolute ("m/..."), which ethers will only derive from
if (!root.privateKey) { // a depth-0 node. The relative form this used to derive would have been
throw new Error("Not an extended private key (xprv)."); // applied *beneath* an account-level key instead of being refused.
} const node = masterXprvOrThrow(xprv).derivePath(BIP44_ETH_PATH);
const node = root.derivePath("44'/60'/0'/0");
const xpub = node.neuter().extendedKey; const xpub = node.neuter().extendedKey;
const firstAddress = node.deriveChild(0).address; const firstAddress = node.deriveChild(0).address;
return { xpub, firstAddress }; return { xpub, firstAddress };
} }
// Well-formed extended private key. Says nothing about depth: the import view
// reports a non-master key separately, since "check it for a typo" is the
// wrong advice for a key the user copied correctly.
function isValidXprv(key) { function isValidXprv(key) {
try { const node = parseExtendedKey(key);
const node = HDNodeWallet.fromExtendedKey(key); return !!(node && node.privateKey);
return !!node.privateKey; }
} catch {
return false; // Whether an extended key is a master key, i.e. the one BIP44_ETH_PATH can be
} // derived from. False for anything parseExtendedKey rejects.
function isMasterExtendedKey(key) {
const node = parseExtendedKey(key);
return !!node && node.depth === MASTER_DEPTH;
} }
function addressFromPrivateKey(key) { function addressFromPrivateKey(key) {
@@ -59,8 +120,8 @@ function getSignerForAddress(walletData, addrIndex, decryptedSecret) {
return node.deriveChild(addrIndex); return node.deriveChild(addrIndex);
} }
if (walletData.type === "xprv") { if (walletData.type === "xprv") {
const root = HDNodeWallet.fromExtendedKey(decryptedSecret); const node =
const node = root.derivePath("44'/60'/0'/0"); masterXprvOrThrow(decryptedSecret).derivePath(BIP44_ETH_PATH);
return node.deriveChild(addrIndex); return node.deriveChild(addrIndex);
} }
return new Wallet(decryptedSecret); return new Wallet(decryptedSecret);
@@ -70,13 +131,24 @@ function isValidMnemonic(mnemonic) {
return Mnemonic.isValidMnemonic(mnemonic); return Mnemonic.isValidMnemonic(mnemonic);
} }
// Only an HD wallet has a recovery phrase. A "key" wallet holds a bare
// private key and an "xprv" wallet an extended private key; neither can be
// turned back into words, so neither may ever be offered the phrase display.
// Written as an allowlist on purpose: a wallet type added later is excluded
// until someone decides otherwise.
function walletHasRecoveryPhrase(walletData) {
return !!walletData && walletData.type === "hd";
}
module.exports = { module.exports = {
generateMnemonic, generateMnemonic,
deriveAddressFromXpub, deriveAddressFromXpub,
hdWalletFromMnemonic, hdWalletFromMnemonic,
hdWalletFromXprv, hdWalletFromXprv,
isValidXprv, isValidXprv,
isMasterExtendedKey,
addressFromPrivateKey, addressFromPrivateKey,
getSignerForAddress, getSignerForAddress,
isValidMnemonic, isValidMnemonic,
walletHasRecoveryPhrase,
}; };

View File

@@ -0,0 +1,70 @@
// Wallet deletion state transition, kept out of the view so the selection
// and broadcast rules are testable without a DOM.
// Remove wallet `walletIdx` from `state` and repair the derived state.
//
// Rules:
// - `hasWallet` tracks whether any wallet remains.
// - Site permissions are dropped for every address of the deleted wallet.
// - `selectedWallet` follows the splice: it is decremented when a wallet
// before it was removed, and falls back to the first remaining wallet's
// first address only when the selection itself was deleted.
// - `activeAddress` is only moved when it belonged to the deleted wallet;
// the fallback is the first remaining wallet's first address, or null
// when no wallet remains.
//
// Returns whether `activeAddress` changed, so the caller can broadcast it.
function removeWalletFromState(state, walletIdx) {
const wallet = state.wallets[walletIdx];
const addresses = (wallet.addresses || []).map((a) => a.address);
const previousActive = state.activeAddress;
const activeWasDeleted =
previousActive !== null &&
previousActive !== undefined &&
addresses.some(
(a) => a.toLowerCase() === String(previousActive).toLowerCase(),
);
state.wallets.splice(walletIdx, 1);
for (const addr of addresses) {
delete state.allowedSites[addr];
delete state.deniedSites[addr];
}
state.hasWallet = state.wallets.length > 0;
const fallbackAddress = state.hasWallet
? state.wallets[0].addresses[0]?.address || null
: null;
if (!state.hasWallet) {
state.selectedWallet = null;
state.selectedAddress = null;
} else if (state.selectedWallet === walletIdx) {
state.selectedWallet = 0;
state.selectedAddress = 0;
} else if (
typeof state.selectedWallet === "number" &&
state.selectedWallet > walletIdx
) {
state.selectedWallet -= 1;
}
if (activeWasDeleted || !state.hasWallet) {
state.activeAddress = fallbackAddress;
}
return { activeAddressChanged: state.activeAddress !== previousActive };
}
// Tell the background the active address changed, so it re-emits
// accountsChanged to connected sites. Same call shape as the address
// switch in the home view.
function broadcastActiveChanged() {
const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
runtime.sendMessage({ type: "AUTISTMASK_ACTIVE_CHANGED" });
}
module.exports = { removeWalletFromState, broadcastActiveChanged };

468
tests/alarms.test.js Normal file
View File

@@ -0,0 +1,468 @@
// Scheduling for the background context.
//
// The Chrome MV3 service worker is terminated after roughly 30 seconds idle,
// so anything scheduled with setInterval/setTimeout dies with it. These tests
// pin the recurring jobs to the alarms API and to the re-registration path a
// revived worker runs.
// A controllable clock plus a stubbed balance refresh, so a cadence test can
// measure the interval between refreshes that actually happened rather than
// asserting the interval someone intended.
let mockNow = 0;
const mockBalanceRefreshAt = [];
// jest.resetModules() clears the call record of every jest.fn, and loading the
// worker is exactly that call — so anything that must be counted across a load
// is counted here rather than read off a mock.
let mockSetIntervalCalls = 0;
// Extension storage reads do not take a constant amount of time, and that is
// what makes a guard timed to the alarm period bite: backgroundRefresh()
// stamps its freshness marker after awaiting loadState(), so any read that is
// quicker than the previous one puts the next tick inside a guard of exactly
// one period and the tick is skipped. A simulation with a constant latency
// would sit exactly on the boundary and hide the bug.
const MOCK_STORAGE_LATENCIES_MS = [7, 3, 11, 2, 9, 4, 13, 1, 6, 5];
const MOCK_MAX_STORAGE_LATENCY_MS = Math.max(...MOCK_STORAGE_LATENCIES_MS);
let mockStorageJitter = false;
let mockStorageOpCount = 0;
function mockStorageTick() {
if (!mockStorageJitter) return;
mockNow +=
MOCK_STORAGE_LATENCIES_MS[
mockStorageOpCount++ % MOCK_STORAGE_LATENCIES_MS.length
];
}
jest.mock("../src/shared/balances", () => ({
refreshBalances: jest.fn(async () => {
mockBalanceRefreshAt.push(Date.now());
}),
getProvider: jest.fn(() => ({})),
}));
function makeAlarmsStub() {
const alarms = new Map();
const listeners = [];
const stub = {
created: [],
alarms,
create: jest.fn((name, info) => {
stub.created.push({ name, info });
alarms.set(name, { name, ...info });
}),
get: jest.fn(async (name) => alarms.get(name)),
clear: jest.fn(async (name) => alarms.delete(name)),
onAlarm: {
addListener: jest.fn((fn) => listeners.push(fn)),
},
fire: (name) => {
for (const fn of listeners) fn({ name });
},
listenerCount: () => listeners.length,
};
return stub;
}
describe("alarms module", () => {
let alarmsStub;
let alarmsMod;
beforeEach(() => {
jest.resetModules();
alarmsStub = makeAlarmsStub();
global.chrome = { alarms: alarmsStub };
alarmsMod = require("../src/shared/alarms");
});
afterEach(() => {
delete global.chrome;
});
test("ensureRecurringAlarms schedules both recurring jobs", async () => {
const created = await alarmsMod.ensureRecurringAlarms();
expect(created).toEqual({ balance: true, phishing: true });
const names = alarmsStub.created.map((c) => c.name).sort();
expect(names).toEqual(
[
alarmsMod.BALANCE_REFRESH_ALARM,
alarmsMod.PHISHING_REFRESH_ALARM,
].sort(),
);
});
test("the balance refresh keeps its 60-second cadence", async () => {
await alarmsMod.ensureRecurringAlarms();
const balance = alarmsStub.alarms.get(alarmsMod.BALANCE_REFRESH_ALARM);
expect(balance.periodInMinutes).toBe(1);
});
test("the phishing refresh keeps its 24-hour cadence", async () => {
await alarmsMod.ensureRecurringAlarms();
const phishing = alarmsStub.alarms.get(
alarmsMod.PHISHING_REFRESH_ALARM,
);
expect(phishing.periodInMinutes).toBe(24 * 60);
});
test("no period is below the browser-enforced minimum", async () => {
// A period under one minute is silently clamped by the browser, so a
// request for one would mean the documented cadence is not the real
// one. Every period must be a whole minute at or above the minimum.
await alarmsMod.ensureRecurringAlarms();
for (const { info } of alarmsStub.created) {
expect(info.periodInMinutes).toBeGreaterThanOrEqual(
alarmsMod.MIN_ALARM_PERIOD_MINUTES,
);
expect(Number.isInteger(info.periodInMinutes)).toBe(true);
}
});
test("a revived worker does not reset an existing alarm's schedule", async () => {
await alarmsMod.ensureRecurringAlarms();
expect(alarmsStub.create).toHaveBeenCalledTimes(2);
// Every wake re-runs the startup path. Re-creating an alarm restarts
// its period, so a busy extension would push the next fire out
// forever and the job would never run.
const again = await alarmsMod.ensureRecurringAlarms();
expect(again).toEqual({ balance: false, phishing: false });
expect(alarmsStub.create).toHaveBeenCalledTimes(2);
});
test("a missing alarm is re-created on the next start", async () => {
await alarmsMod.ensureRecurringAlarms();
await alarmsStub.clear(alarmsMod.BALANCE_REFRESH_ALARM);
const again = await alarmsMod.ensureRecurringAlarms();
expect(again).toEqual({ balance: true, phishing: false });
expect(
alarmsStub.alarms.get(alarmsMod.BALANCE_REFRESH_ALARM),
).toBeDefined();
});
test("an alarm left over with a stale period is re-created", async () => {
// An install carries its alarms across an extension update, so a
// period changed in a new release only ever reaches users if the
// stale one is reconciled.
alarmsStub.create(alarmsMod.PHISHING_REFRESH_ALARM, {
periodInMinutes: 7 * 24 * 60,
});
alarmsStub.create.mockClear();
const created = await alarmsMod.ensureRecurringAlarms();
expect(created.phishing).toBe(true);
expect(
alarmsStub.alarms.get(alarmsMod.PHISHING_REFRESH_ALARM)
.periodInMinutes,
).toBe(alarmsMod.PHISHING_REFRESH_PERIOD_MINUTES);
});
test("reconciling a period settles instead of re-creating forever", async () => {
alarmsStub.create(alarmsMod.BALANCE_REFRESH_ALARM, {
periodInMinutes: 30,
});
await alarmsMod.ensureRecurringAlarms();
alarmsStub.create.mockClear();
const again = await alarmsMod.ensureRecurringAlarms();
expect(again).toEqual({ balance: false, phishing: false });
expect(alarmsStub.create).not.toHaveBeenCalled();
});
test("handlers are dispatched by alarm name from one listener", () => {
const balance = jest.fn();
const phishing = jest.fn();
expect(
alarmsMod.registerAlarmHandlers({
[alarmsMod.BALANCE_REFRESH_ALARM]: balance,
[alarmsMod.PHISHING_REFRESH_ALARM]: phishing,
}),
).toBe(true);
expect(alarmsStub.listenerCount()).toBe(1);
alarmsStub.fire(alarmsMod.BALANCE_REFRESH_ALARM);
expect(balance).toHaveBeenCalledTimes(1);
expect(phishing).not.toHaveBeenCalled();
alarmsStub.fire(alarmsMod.PHISHING_REFRESH_ALARM);
expect(phishing).toHaveBeenCalledTimes(1);
alarmsStub.fire("some-other-extension-alarm");
expect(balance).toHaveBeenCalledTimes(1);
expect(phishing).toHaveBeenCalledTimes(1);
});
test("Firefox MV2 gets the same treatment via browser.alarms", async () => {
// Both targets are built from one bundle. MV2 has a persistent
// background page, but it takes the alarm path too, so the schedule
// is the same code on both browsers.
jest.resetModules();
const firefoxAlarms = makeAlarmsStub();
global.browser = { alarms: firefoxAlarms };
try {
const mod = require("../src/shared/alarms");
const created = await mod.ensureRecurringAlarms();
expect(created).toEqual({ balance: true, phishing: true });
expect(firefoxAlarms.created).toHaveLength(2);
// The Chrome stub must not have been touched.
expect(alarmsStub.create).not.toHaveBeenCalled();
} finally {
delete global.browser;
}
});
test("a context without the alarms API degrades instead of throwing", async () => {
jest.resetModules();
delete global.chrome;
const mod = require("../src/shared/alarms");
await expect(mod.ensureRecurringAlarms()).resolves.toEqual({
balance: false,
phishing: false,
});
expect(mod.registerAlarmHandlers({})).toBe(false);
});
});
// Loads the background worker against stubbed browser APIs. The returned
// store is the extension storage the worker sees, so a test can seed wallet
// state and read back what the worker persisted.
function loadBackground(initialStore = {}) {
const storageStore = initialStore;
const alarmsStub = makeAlarmsStub();
const listeners = { onInstalled: [], onStartup: [] };
global.chrome = {
alarms: alarmsStub,
storage: {
local: {
get: async (key) => {
mockStorageTick();
return Object.prototype.hasOwnProperty.call(
storageStore,
key,
)
? { [key]: storageStore[key] }
: {};
},
set: async (items) => {
mockStorageTick();
Object.assign(storageStore, items);
},
remove: async (key) => {
delete storageStore[key];
},
},
},
runtime: {
onMessage: { addListener: jest.fn() },
onConnect: { addListener: jest.fn() },
onInstalled: {
addListener: jest.fn((fn) => listeners.onInstalled.push(fn)),
},
onStartup: {
addListener: jest.fn((fn) => listeners.onStartup.push(fn)),
},
getURL: (p) => "chrome-extension://test/" + p,
lastError: null,
},
windows: {
onRemoved: { addListener: jest.fn() },
create: jest.fn(),
},
tabs: { query: jest.fn(), sendMessage: jest.fn() },
action: { setPopup: jest.fn() },
};
global.fetch = jest.fn(async () => ({
ok: true,
json: async () => ({ blacklist: [] }),
}));
jest.resetModules();
require("../src/background/index");
return { alarmsStub, listeners, store: storageStore };
}
// Flush the promise chains the startup path and the alarm handlers run on.
async function settle() {
for (let i = 0; i < 3; i++) {
await new Promise((resolve) => setImmediate(resolve));
}
}
describe("background worker scheduling", () => {
let alarmsStub;
let timers;
beforeEach(() => {
mockSetIntervalCalls = 0;
timers = {
setInterval: jest
.spyOn(global, "setInterval")
.mockImplementation(() => {
mockSetIntervalCalls++;
return 0;
}),
};
});
afterEach(() => {
timers.setInterval.mockRestore();
delete global.chrome;
delete global.fetch;
jest.resetModules();
});
test("startup schedules the recurring jobs as alarms, not timers", async () => {
alarmsStub = loadBackground().alarmsStub;
// Let the startup path's promises settle.
await settle();
const names = alarmsStub.created.map((c) => c.name).sort();
const {
BALANCE_REFRESH_ALARM,
PHISHING_REFRESH_ALARM,
} = require("../src/shared/alarms");
expect(names).toEqual(
[BALANCE_REFRESH_ALARM, PHISHING_REFRESH_ALARM].sort(),
);
expect(mockSetIntervalCalls).toBe(0);
});
test("an onAlarm listener is installed on startup", async () => {
alarmsStub = loadBackground().alarmsStub;
await settle();
expect(alarmsStub.listenerCount()).toBe(1);
});
test("onInstalled and onStartup both re-establish the schedule", async () => {
const loaded = loadBackground();
alarmsStub = loaded.alarmsStub;
await settle();
expect(loaded.listeners.onInstalled).toHaveLength(1);
expect(loaded.listeners.onStartup).toHaveLength(1);
// A browser start after the alarms were dropped must put them back.
alarmsStub.alarms.clear();
alarmsStub.created.length = 0;
loaded.listeners.onStartup[0]();
await settle();
expect(alarmsStub.created).toHaveLength(2);
});
test("the install-time listener and the top-level call share one run", async () => {
// On a fresh install both fire, close enough that both could observe
// an alarm missing and create it — and a second create restarts the
// period the first one just set.
const loaded = loadBackground();
alarmsStub = loaded.alarmsStub;
loaded.listeners.onInstalled[0]();
await settle();
expect(alarmsStub.created).toHaveLength(2);
expect(alarmsStub.created.map((c) => c.name).sort()).toEqual(
[
"autistmask-balance-refresh",
"autistmask-phishing-refresh",
].sort(),
);
});
});
// The alarm period alone must set the cadence. A freshness guard timed to the
// period vetoes the very tick it gates, because the guard is measured from
// when the last run finished and the alarm fires one run-duration before that.
// These tests measure the interval between refreshes that actually ran.
describe("balance refresh steady-state cadence", () => {
const {
BALANCE_REFRESH_PERIOD_MINUTES,
BALANCE_REFRESH_ALARM,
} = require("../src/shared/alarms");
const PERIOD_MS = BALANCE_REFRESH_PERIOD_MINUTES * 60 * 1000;
let clockSpy;
let timerSpy;
function seededStore() {
return {
autistmask: {
hasWallet: true,
wallets: [
{ address: "0x0000000000000000000000000000000000000001" },
],
lastBalanceRefresh: 0,
},
};
}
beforeEach(() => {
mockNow = Date.UTC(2026, 0, 1, 0, 0, 0);
mockBalanceRefreshAt.length = 0;
mockSetIntervalCalls = 0;
mockStorageOpCount = 0;
mockStorageJitter = false;
clockSpy = jest.spyOn(Date, "now").mockImplementation(() => mockNow);
timerSpy = jest.spyOn(global, "setInterval").mockImplementation(() => {
mockSetIntervalCalls++;
return 0;
});
});
afterEach(() => {
mockStorageJitter = false;
clockSpy.mockRestore();
timerSpy.mockRestore();
delete global.chrome;
delete global.fetch;
jest.resetModules();
});
test("ten alarm ticks produce ten refreshes, one per period", async () => {
const { alarmsStub } = loadBackground(seededStore());
await settle();
mockStorageJitter = true;
const TICKS = 10;
let tickAt = mockNow + PERIOD_MS;
for (let i = 0; i < TICKS; i++) {
mockNow = tickAt;
tickAt += PERIOD_MS;
alarmsStub.fire(BALANCE_REFRESH_ALARM);
await settle();
}
// No tick was a no-op. This is the assertion that fails when the guard
// is timed to the alarm period.
expect(mockBalanceRefreshAt).toHaveLength(TICKS);
// And the observed cadence is one period, not two.
const intervals = mockBalanceRefreshAt
.slice(1)
.map((t, i) => t - mockBalanceRefreshAt[i]);
for (const interval of intervals) {
expect(interval).toBeGreaterThanOrEqual(
PERIOD_MS - MOCK_MAX_STORAGE_LATENCY_MS,
);
expect(interval).toBeLessThanOrEqual(
PERIOD_MS + MOCK_MAX_STORAGE_LATENCY_MS,
);
}
});
test("a refresh an open popup just did still suppresses the tick", async () => {
// The guard's actual job, and the reason it is shortened rather than
// removed: while the popup is open it refreshes every 10 seconds and
// stamps the same field, and the background job has nothing to add.
const store = seededStore();
const { alarmsStub } = loadBackground(store);
await settle();
mockNow += PERIOD_MS;
store.autistmask.lastBalanceRefresh = mockNow - 10 * 1000;
alarmsStub.fire(BALANCE_REFRESH_ALARM);
await settle();
expect(mockBalanceRefreshAt).toHaveLength(0);
});
});

View File

@@ -0,0 +1,355 @@
const { Network, Transaction, Wallet } = require("ethers");
const {
verifySignedTx,
verifySignature,
sameAddress,
} = require("../src/shared/approvalVerify");
const { getSignerForAddress } = require("../src/shared/wallet");
// Fixed test keys — never used for anything but these tests.
const SIGNER_KEY =
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
const OTHER_KEY =
"0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a";
const signer = new Wallet(SIGNER_KEY);
const other = new Wallet(OTHER_KEY);
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const OTHER_RECIPIENT = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
// Approved parameters as a dApp would supply them over eth_sendTransaction.
const TX_PARAMS = {
from: signer.address,
to: RECIPIENT,
value: "0x2386f26fc10000",
data: "0xdeadbeef",
gas: "0x5208",
};
// Build a signable transaction from approved params. The popup does the same
// thing through populateTransaction(); here the fields are fixed so the test
// needs no provider.
function txFor(params) {
return {
chainId: 1,
nonce: 7,
gasLimit: 100000n,
maxFeePerGas: 2000000000n,
maxPriorityFeePerGas: 1000000000n,
type: 2,
to: params.to,
value: params.value === undefined ? 0n : BigInt(params.value),
data: params.data || "0x",
};
}
async function signedFor(params, withWallet) {
return (withWallet || signer).signTransaction(txFor(params));
}
describe("sameAddress", () => {
test("compares checksummed and lowercase forms as equal", () => {
expect(sameAddress(RECIPIENT, RECIPIENT.toLowerCase())).toBe(true);
});
test("treats two absent addresses as equal (contract creation)", () => {
expect(sameAddress(null, undefined)).toBe(true);
expect(sameAddress("", null)).toBe(true);
});
test("treats one absent address as unequal", () => {
expect(sameAddress(RECIPIENT, null)).toBe(false);
expect(sameAddress(null, RECIPIENT)).toBe(false);
});
test("does not throw on values that are not addresses", () => {
expect(sameAddress("not-an-address", RECIPIENT)).toBe(false);
});
});
describe("verifySignedTx", () => {
test("accepts the approved transaction signed by the approved address", async () => {
const raw = await signedFor(TX_PARAMS);
const parsed = verifySignedTx(raw, TX_PARAMS, signer.address);
expect(parsed.from).toBe(signer.address);
expect(parsed.hash).toBe(Transaction.from(raw).hash);
});
test("accepts a contract creation with no recipient", async () => {
const params = { to: undefined, value: "0x0", data: "0x600160005500" };
const raw = await signedFor(params);
expect(() => verifySignedTx(raw, params, signer.address)).not.toThrow();
});
test("accepts an absent value as zero", async () => {
const approved = { to: RECIPIENT, data: "0x" };
const raw = await signedFor(approved);
expect(() =>
verifySignedTx(raw, approved, signer.address),
).not.toThrow();
});
test("accepts call data whose case differs from the approval", async () => {
const approved = { to: RECIPIENT, value: "0x0", data: "0xDEADBEEF" };
const raw = await signedFor(approved);
expect(() =>
verifySignedTx(raw, approved, signer.address),
).not.toThrow();
});
test("rejects a swapped recipient", async () => {
const raw = await signedFor({
...TX_PARAMS,
to: OTHER_RECIPIENT,
});
expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow(
/approved recipient/,
);
});
test("rejects an inflated value", async () => {
const raw = await signedFor({
...TX_PARAMS,
value: "0x4563918244f40000",
});
expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow(
/approved value/,
);
});
test("rejects substituted call data", async () => {
const raw = await signedFor({ ...TX_PARAMS, data: "0xc0ffee" });
expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow(
/approved call data/,
);
});
test("rejects a transaction signed by a different address", async () => {
const raw = await signedFor(TX_PARAMS, other);
expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow(
/different address/,
);
});
test("rejects an unsigned transaction", () => {
const unsigned = Transaction.from(txFor(TX_PARAMS)).unsignedSerialized;
expect(() =>
verifySignedTx(unsigned, TX_PARAMS, signer.address),
).toThrow(/no valid signature/);
});
test("rejects a missing or malformed payload", () => {
expect(() =>
verifySignedTx(undefined, TX_PARAMS, signer.address),
).toThrow(/missing or malformed/);
expect(() => verifySignedTx("nope", TX_PARAMS, signer.address)).toThrow(
/missing or malformed/,
);
expect(() =>
verifySignedTx("0xc0ffee", TX_PARAMS, signer.address),
).toThrow(/could not be decoded/);
});
test("every rejection message is a full sentence", async () => {
const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT });
try {
verifySignedTx(raw, TX_PARAMS, signer.address);
throw new Error("expected a rejection");
} catch (e) {
expect(e.message).toMatch(/^[A-Z].*\.$/);
}
});
});
const TYPED_DATA = JSON.stringify({
domain: {
name: "AutistMask Test",
version: "1",
chainId: 1,
verifyingContract: OTHER_RECIPIENT,
},
primaryType: "Mail",
types: {
EIP712Domain: [
{ name: "name", type: "string" },
{ name: "version", type: "string" },
{ name: "chainId", type: "uint256" },
{ name: "verifyingContract", type: "address" },
],
Mail: [
{ name: "from", type: "address" },
{ name: "to", type: "address" },
{ name: "contents", type: "string" },
],
},
message: {
from: signer.address,
to: RECIPIENT,
contents: "hello",
},
});
describe("verifySignature", () => {
// "Hello AutistMask" as the hex string a dApp passes to personal_sign.
const MESSAGE = "0x48656c6c6f204175746973744d61736b";
const personalParams = {
method: "personal_sign",
message: MESSAGE,
from: signer.address,
};
const typedParams = {
method: "eth_signTypedData_v4",
typedData: TYPED_DATA,
from: signer.address,
};
async function signPersonal(withWallet) {
return (withWallet || signer).signMessage(
Buffer.from(MESSAGE.slice(2), "hex"),
);
}
async function signTyped(withWallet) {
const { domain, types, message } = JSON.parse(TYPED_DATA);
delete types.EIP712Domain;
return (withWallet || signer).signTypedData(domain, types, message);
}
test("accepts a personal_sign signature from the approved address", async () => {
const signature = await signPersonal();
expect(verifySignature(personalParams, signature, signer.address)).toBe(
signer.address,
);
});
test("accepts an eth_sign signature the same way", async () => {
const signature = await signPersonal();
const params = { ...personalParams, method: "eth_sign" };
expect(() =>
verifySignature(params, signature, signer.address),
).not.toThrow();
});
test("accepts a typed data signature from the approved address", async () => {
const signature = await signTyped();
expect(verifySignature(typedParams, signature, signer.address)).toBe(
signer.address,
);
});
test("does not mutate the approved typed data while verifying", async () => {
const signature = await signTyped();
const before = typedParams.typedData;
verifySignature(typedParams, signature, signer.address);
expect(typedParams.typedData).toBe(before);
expect(
JSON.parse(typedParams.typedData).types.EIP712Domain,
).toBeDefined();
});
test("rejects a personal_sign signature from a different address", async () => {
const signature = await signPersonal(other);
expect(() =>
verifySignature(personalParams, signature, signer.address),
).toThrow(/different address/);
});
test("rejects a typed data signature from a different address", async () => {
const signature = await signTyped(other);
expect(() =>
verifySignature(typedParams, signature, signer.address),
).toThrow(/different address/);
});
test("rejects a signature over a different message", async () => {
const signature = await signer.signMessage(
Buffer.from("00112233", "hex"),
);
expect(() =>
verifySignature(personalParams, signature, signer.address),
).toThrow(/different address/);
});
test("rejects a missing or malformed signature", async () => {
expect(() =>
verifySignature(personalParams, undefined, signer.address),
).toThrow(/missing or malformed/);
expect(() =>
verifySignature(personalParams, "0x1234", signer.address),
).toThrow(/could not be verified/);
});
});
// End-to-end over the messaging boundary, without a browser: run the exact
// sequence the approval popup runs, then hand the artifact to the exact check
// the background runs before it broadcasts or resolves. Only what the popup
// puts on the wire is passed along, so this also pins down that the wire
// payload is sufficient on its own.
describe("popup signing sequence to background verification", () => {
// Stand-in for the JSON-RPC provider. populateTransaction only needs the
// nonce, the gas estimate, the network and the fee data.
const fakeProvider = {
getNetwork: async () => Network.from(1),
getTransactionCount: async () => 7,
estimateGas: async () => 21000n,
getFeeData: async () => ({
gasPrice: 2000000000n,
maxFeePerGas: 2000000000n,
maxPriorityFeePerGas: 1000000000n,
}),
};
// A private-key wallet as it is persisted in state, so the test goes
// through getSignerForAddress() the way the popup does.
const walletData = { type: "privkey" };
async function popupSignsTx(txParams) {
const localSigner = getSignerForAddress(walletData, 0, SIGNER_KEY);
const connected = localSigner.connect(fakeProvider);
const populated = await connected.populateTransaction(txParams);
delete populated.from;
return connected.signTransaction(populated);
}
test("a populated, signed transaction is accepted and broadcastable", async () => {
const rawSignedTx = await popupSignsTx(TX_PARAMS);
const parsed = verifySignedTx(rawSignedTx, TX_PARAMS, signer.address);
expect(parsed.nonce).toBe(7);
expect(parsed.chainId).toBe(1n);
expect(parsed.gasLimit).toBe(21000n);
expect(parsed.to).toBe(RECIPIENT);
expect(parsed.value).toBe(BigInt(TX_PARAMS.value));
expect(parsed.data).toBe(TX_PARAMS.data);
expect(parsed.signature).not.toBeNull();
});
test("the wire payload carries no password and no secret", async () => {
const rawSignedTx = await popupSignsTx(TX_PARAMS);
const payload = {
type: "AUTISTMASK_TX_RESPONSE",
id: "test-approval-id",
approved: true,
rawSignedTx,
};
expect(Object.keys(payload).sort()).toEqual([
"approved",
"id",
"rawSignedTx",
"type",
]);
const wire = JSON.stringify(payload).toLowerCase();
expect(wire).not.toContain("password");
expect(wire).not.toContain(SIGNER_KEY.slice(2).toLowerCase());
});
test("the background rejects a transaction the popup did not approve", async () => {
const rawSignedTx = await popupSignsTx({
...TX_PARAMS,
to: OTHER_RECIPIENT,
});
expect(() =>
verifySignedTx(rawSignedTx, TX_PARAMS, signer.address),
).toThrow(/approved recipient/);
});
});

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);

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

@@ -0,0 +1,297 @@
// 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.
//
// Returns the recovery phrase it generated. Tests that assert on a secret
// need the real value — checking for "some 12 words" would pass against the
// wrong wallet's phrase, and checking for nothing at all would pass against
// a screen that shows the phrase it was supposed to hide.
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;
});
const phrase = (await page.inputValue("#wallet-mnemonic")).trim();
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);
return phrase;
}
// 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 = {
PASSWORD,
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,
};

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

@@ -0,0 +1,526 @@
// 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 {
PASSWORD,
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 sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
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) => {
env.phrase = await createWallet(env.page);
assert(
env.phrase.split(/\s+/).length >= 12,
"wallet creation did not yield a recovery phrase",
);
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");
});
// -------------------------------------------- recovery phrase (#161)
// The gear toggles, so pressing it while Settings is already up leaves it.
async function openSettings(page) {
if (!(await page.isVisible("#view-settings"))) {
await page.click("#btn-settings");
}
await visible(page, "#view-settings");
}
// Everything the recovery phrase screen is holding, read straight out of
// the DOM whether or not that screen is the one on top. Reading it while it
// is hidden is the point: "cleared on leave" means the node is empty, not
// merely off-screen.
async function phraseScreenState(page) {
return page.evaluate(() => ({
value: document.getElementById("show-phrase-value").textContent,
error: document.getElementById("show-phrase-flash").textContent,
html: document.getElementById("view-show-phrase").innerHTML,
resultHidden: document
.getElementById("show-phrase-result")
.classList.contains("hidden"),
viewHidden: document
.getElementById("view-show-phrase")
.classList.contains("hidden"),
}));
}
async function openPhraseScreen(page) {
await openSettings(page);
await page.click("#settings-wallet-list .btn-show-phrase");
await visible(page, "#view-show-phrase");
}
async function revealPhrase(page) {
await page.fill("#show-phrase-password", PASSWORD);
await page.click("#btn-show-phrase-reveal");
await visible(page, "#show-phrase-result", 60000);
}
function assertWiped(st, phrase, where) {
assert(st.value === "", "phrase still in the DOM " + where);
assert(st.resultHidden, "result section still shown " + where);
assert(
!st.html.includes(phrase),
"the recovery phrase is still somewhere in the screen markup " + where,
);
}
test("only an HD wallet is offered the recovery phrase action (#161)", async (env) => {
await openSettings(env.page);
const offered = await env.page
.locator("#settings-wallet-list .btn-show-phrase")
.count();
const wallets = await env.page
.locator("#settings-wallet-list .btn-delete-wallet")
.count();
assert(wallets === 1, "expected exactly one wallet row, got " + wallets);
assert(
offered === 1,
"the HD wallet was not offered the recovery phrase action",
);
});
// The other half of the gate, against the real UI: a wallet holding a bare
// private key has no phrase to show, so no row of it may offer the action.
// The key is generated here rather than committed — the repo holds no
// private keys, test ones included.
test("a key wallet is not offered the recovery phrase action (#161)", async (env) => {
const { Wallet } = require("ethers");
await openSettings(env.page);
await env.page.click("#btn-main-add-wallet");
await visible(env.page, "#view-add-wallet");
await env.page.click("#tab-privkey");
await env.page.fill(
"#import-private-key",
Wallet.createRandom().privateKey,
);
await env.page.fill("#add-wallet-password", PASSWORD);
await env.page.fill("#add-wallet-password-confirm", PASSWORD);
await env.page.click("#btn-add-wallet-confirm");
await visible(env.page, "#view-main", 60000);
await openSettings(env.page);
const wallets = await env.page
.locator("#settings-wallet-list .btn-delete-wallet")
.count();
const offered = await env.page
.locator("#settings-wallet-list .btn-show-phrase")
.count();
assert(wallets === 2, "expected two wallet rows, got " + wallets);
assert(
offered === 1,
"the key wallet was offered the recovery phrase action",
);
});
test("the recovery phrase screen holds nothing before the password (#161)", async (env) => {
await openPhraseScreen(env.page);
const st = await phraseScreenState(env.page);
assertWiped(st, env.phrase, "before any password was entered");
const passwordShown = await env.page.isVisible(
"#show-phrase-password-section",
);
assert(passwordShown, "the password prompt is not shown");
});
test("a wrong password reveals nothing (#161)", async (env) => {
await env.page.fill("#show-phrase-password", "not-the-password");
await env.page.click("#btn-show-phrase-reveal");
await env.page.waitForFunction(
() =>
document.getElementById("show-phrase-flash").textContent.length > 0,
null,
{ timeout: 60000 },
);
const st = await phraseScreenState(env.page);
assertWiped(st, env.phrase, "after a wrong password");
assert(
/^[A-Z].*\.$/.test(st.error.trim()),
"the wrong-password error is not a full sentence: " +
JSON.stringify(st.error),
);
});
test("the correct password reveals the full phrase, and nothing logs it (#161)", async (env) => {
const console_ = [];
const listener = (msg) => console_.push(msg.text());
env.page.on("console", listener);
try {
await revealPhrase(env.page);
const st = await phraseScreenState(env.page);
assert(
st.value === env.phrase,
"the displayed phrase is not the wallet's phrase, verbatim",
);
const promptShown = await env.page.isVisible(
"#show-phrase-password-section",
);
assert(!promptShown, "the password prompt is still shown after unlock");
// Full Identifiers Policy: shown whole, and copyable.
const title = await env.page.getAttribute(
"#show-phrase-value",
"title",
);
assert(title === "Click to copy", "the phrase is not click-to-copy");
const leaked = console_.filter((line) => line.includes(env.phrase));
assert(
leaked.length === 0,
"the recovery phrase reached the console: " +
JSON.stringify(leaked),
);
} finally {
env.page.off("console", listener);
}
});
test('"Back" wipes the revealed phrase (#161)', async (env) => {
await env.page.click("#btn-show-phrase-back");
await visible(env.page, "#view-settings");
const st = await phraseScreenState(env.page);
assert(st.viewHidden, "the recovery phrase screen is still on top");
assertWiped(st, env.phrase, "after Back");
});
// The settings gear leaves the screen without touching its Back button. A
// clear wired only to Back would pass the test above and leak here.
test("leaving by the settings gear wipes it too (#161)", async (env) => {
await openPhraseScreen(env.page);
await revealPhrase(env.page);
await env.page.click("#btn-settings");
await visible(env.page, "#view-settings");
const st = await phraseScreenState(env.page);
assertWiped(st, env.phrase, "after leaving via the settings gear");
});
// The same leave, but taken while the decrypt is still running. Both
// clicks are dispatched inside one page task on purpose: "Reveal" runs its
// handler up to the await, the gear then runs the leave — and the wipe with
// it — to completion, and the decrypt's continuation resumes afterwards.
// Without a liveness check that continuation writes the phrase into the
// hidden screen after the wipe, and nothing is left to wipe it again.
//
// A human cannot produce this interleaving by hand once libsodium's wasm is
// warm, because crypto_pwhash is synchronous and the only suspension point
// is a microtask; the window a user can actually hit is a still-pending
// sodium.ready on the first vault use of a page load. Forcing it here is
// the only way to test the guard deterministically.
test("leaving while the decrypt is in flight reveals nothing (#161)", async (env) => {
await openPhraseScreen(env.page);
await env.page.fill("#show-phrase-password", PASSWORD);
await env.page.evaluate(() => {
document.getElementById("btn-show-phrase-reveal").click();
document.getElementById("btn-settings").click();
});
await visible(env.page, "#view-settings");
// The Reveal button is disabled for exactly the duration of the
// decrypt and re-enabled in the same continuation that would have
// written the phrase, so waiting for it to come back is a precise
// "the decrypt has settled and its handler has finished" signal
// rather than a guess at a duration.
await env.page.waitForFunction(
() => !document.getElementById("btn-show-phrase-reveal").disabled,
null,
{ timeout: 60000 },
);
await sleep(2000);
const st = await phraseScreenState(env.page);
// Printed on every run, pass or fail: "the phrase is not there" is
// worth more as a measurement than as a silent assertion, and the
// same line read from a build without the guard is what this test
// exists to prevent.
console.log(
"# probe: len=" +
st.value.length +
" equalsPhrase=" +
(st.value === env.phrase) +
" resultHidden=" +
st.resultHidden +
" viewHidden=" +
st.viewHidden,
);
assert(st.viewHidden, "the recovery phrase screen is still on top");
assertWiped(st, env.phrase, "after leaving mid-decrypt");
});
// Closing and reopening the page rather than reloading it: that is what
// the toolbar popup actually does, and the persisted currentView is
// "show-phrase" at the moment it happens, which is precisely the state
// RESTORABLE_VIEWS has to refuse.
test("reopening the popup never lands on the phrase screen (#161)", async (env) => {
await openPhraseScreen(env.page);
await revealPhrase(env.page);
await env.page.close();
env.page = await openPopup(env.ctx, env.popupUrl);
await visible(env.page, "#view-main");
const st = await phraseScreenState(env.page);
assert(st.viewHidden, "the popup reopened onto the recovery phrase screen");
assertWiped(st, env.phrase, "after reopening the popup");
});
// ---------------------------------------------------------------- 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,
// The recovery phrase of the wallet created in test 2, so later
// tests can assert on the real secret rather than its shape.
phrase: 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;
});

331
tests/exportPrivkey.test.js Normal file
View File

@@ -0,0 +1,331 @@
// Tests for the private key export screen (issue #221).
//
// The screen holds the one secret that owns an address outright, so what is
// pinned here is disposal: the key is wiped from the DOM whenever the screen
// is left by any route, and a decrypt still in flight when the screen is
// left never writes at all. That last case is the one a per-button wipe and
// a naive leave hook both miss — the write lands after the wipe, with
// nothing scheduled to wipe it again.
//
// The view is driven against a minimal DOM stub rather than a real browser:
// the module is deliberately shaped like src/popup/views/showPhrase.js, with
// no dependency that needs a document beyond the nodes it reads and writes.
const mockPrivateKey = "0x" + "ab".repeat(32);
jest.mock("ethereum-blockies-base64", () => () => "data:image/png;base64,x");
jest.mock("../src/shared/vault", () => ({
decryptWithPassword: jest.fn(),
}));
jest.mock("../src/shared/wallet", () => ({
getSignerForAddress: jest.fn(() => ({ privateKey: mockPrivateKey })),
}));
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
const VIEW = "export-privkey";
const PASSWORD = "correct horse battery";
// ------------------------------------------------------------ DOM stub
function makeElement(id, withParent) {
const classes = new Set();
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
disabled: false,
style: {},
dataset: {},
listeners: {},
classList: {
add: (...names) => names.forEach((n) => classes.add(n)),
remove: (...names) => names.forEach((n) => classes.delete(n)),
contains: (n) => classes.has(n),
toggle: (n, force) => {
const on = force === undefined ? !classes.has(n) : force;
if (on) classes.add(n);
else classes.delete(n);
return on;
},
},
addEventListener: (name, fn) => {
el.listeners[name] = el.listeners[name] || [];
el.listeners[name].push(fn);
},
appendChild: () => {},
remove: () => {},
querySelectorAll: () => [],
};
el.parentElement = withParent ? makeElement(id + "-parent", false) : null;
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
// The debug banner is created on demand by helpers.js; absent
// is the state a non-debug, non-testnet popup is in.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id, true));
return els.get(id);
},
createElement: () => makeElement("created", false),
addEventListener: () => {},
body: { prepend: () => {} },
};
}
// ------------------------------------------------------------ harness
function load() {
jest.resetModules();
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
globalThis.document = makeDocument();
const helpers = require("../src/popup/views/helpers");
const { state } = require("../src/shared/state");
const vault = require("../src/shared/vault");
const wallet = require("../src/shared/wallet");
const exportPrivkey = require("../src/popup/views/exportPrivkey");
state.wallets = [
{
name: "Wallet 1",
type: "key",
encryptedSecret: "ciphertext",
addresses: [
{
address: "0x" + "11".repeat(20),
balance: "0.0000",
tokenBalances: [],
},
{
address: "0x" + "22".repeat(20),
balance: "0.0000",
tokenBalances: [],
},
],
},
];
state.viewStack = [];
state.currentView = "address";
exportPrivkey.init();
return { helpers, state, vault, wallet, exportPrivkey };
}
function click(id) {
const el = globalThis.document.getElementById(id);
return Promise.all((el.listeners.click || []).map((fn) => fn()));
}
function node(id) {
return globalThis.document.getElementById(id);
}
// Start a reveal and hand back both the promise it returns and the resolver
// for the decrypt it is waiting on, so a test can navigate away mid-flight.
function startReveal(vault) {
let resolveDecrypt;
let rejectDecrypt;
vault.decryptWithPassword.mockImplementation(
() =>
new Promise((resolve, reject) => {
resolveDecrypt = resolve;
rejectDecrypt = reject;
}),
);
node("export-privkey-password").value = PASSWORD;
const pending = click("btn-export-privkey-confirm");
return {
pending,
resolve: (v) => resolveDecrypt(v),
reject: (e) => rejectDecrypt(e),
};
}
// ------------------------------------------------------------ tests
describe("a decrypt still running when the screen is left", () => {
// The load-bearing case. Without the liveness guard in reveal(), the
// write lands after the leave hook has already wiped, and the key sits
// in the hidden view for the life of the popup.
test("never writes the key into the DOM", async () => {
const { helpers, vault, wallet, exportPrivkey } = load();
exportPrivkey.show(0, 0);
const reveal = startReveal(vault);
// The settings gear, mid-decrypt.
helpers.showView("settings");
reveal.resolve("wallet secret");
await reveal.pending;
expect(node("export-privkey-value").textContent).toBe("");
// Nothing was even derived: the guard sits in front of the
// derivation, not just in front of the write.
expect(wallet.getSignerForAddress).not.toHaveBeenCalled();
});
// The generation counter, not merely the current-view check: by the time
// the stale decrypt resolves the user is back on the screen, so a guard
// that only asked "is this view showing?" would let the write through.
test("never writes it after the screen is re-entered", async () => {
const { helpers, vault, exportPrivkey } = load();
exportPrivkey.show(0, 0);
const stale = startReveal(vault);
helpers.showView("settings");
exportPrivkey.show(0, 1);
expect(node("export-privkey-value").textContent).toBe("");
stale.resolve("wallet secret");
await stale.pending;
expect(node("export-privkey-value").textContent).toBe("");
expect(node("export-privkey-result").classList.contains("hidden")).toBe(
true,
);
});
// Same hole on the failure path: a wrong-password error written after
// the wipe would restore the flash line on a screen the user has left.
test("never writes the failure message either", async () => {
const { helpers, vault, exportPrivkey } = load();
exportPrivkey.show(0, 0);
const reveal = startReveal(vault);
helpers.showView("settings");
reveal.reject(new Error("decryption failed"));
await reveal.pending;
expect(node("export-privkey-flash").textContent).toBe("");
expect(node("export-privkey-flash").style.visibility).toBe("hidden");
});
});
describe("a reveal that is not interrupted", () => {
// Guards the guard: a liveness check that rejected every write would
// pass every test above and ship a screen that reveals nothing.
test("puts the key on screen", async () => {
const { vault, exportPrivkey } = load();
exportPrivkey.show(0, 0);
const reveal = startReveal(vault);
reveal.resolve("wallet secret");
await reveal.pending;
expect(node("export-privkey-value").textContent).toBe(mockPrivateKey);
expect(node("export-privkey-result").classList.contains("hidden")).toBe(
false,
);
// The password is dropped as soon as it has been spent.
expect(node("export-privkey-password").value).toBe("");
});
test("writes nothing before the password is accepted", async () => {
const { vault, exportPrivkey } = load();
exportPrivkey.show(0, 0);
const reveal = startReveal(vault);
expect(node("export-privkey-value").textContent).toBe("");
reveal.resolve("wallet secret");
await reveal.pending;
});
test("reveals nothing when the password is wrong", async () => {
const { vault, exportPrivkey } = load();
exportPrivkey.show(0, 0);
const reveal = startReveal(vault);
reveal.reject(new Error("decryption failed"));
await reveal.pending;
expect(node("export-privkey-value").textContent).toBe("");
expect(node("export-privkey-flash").textContent).toBe(
"That password is not correct. Please try again.",
);
});
});
describe("leaving the screen after the key is on it", () => {
async function revealed() {
const loaded = load();
loaded.exportPrivkey.show(0, 0);
const reveal = startReveal(loaded.vault);
reveal.resolve("wallet secret");
await reveal.pending;
expect(node("export-privkey-value").textContent).toBe(mockPrivateKey);
return loaded;
}
test("the Back button clears the key", async () => {
await revealed();
await click("btn-export-privkey-back");
expect(node("export-privkey-value").textContent).toBe("");
expect(node("export-privkey-password").value).toBe("");
});
test("the settings gear clears the key", async () => {
const { helpers } = await revealed();
helpers.showView("settings");
expect(node("export-privkey-value").textContent).toBe("");
expect(node("export-privkey-password").value).toBe("");
// And the screen is back to its password prompt, not to a result
// panel that would flash an empty well on the next visit.
expect(node("export-privkey-result").classList.contains("hidden")).toBe(
true,
);
expect(
node("export-privkey-password-section").classList.contains(
"hidden",
),
).toBe(false);
});
// Any other navigation: the same hook covers routes that do not exist
// yet, which is the point of registering it on the view rather than on
// the controls that leave it.
test("any other navigation clears the key", async () => {
const { helpers } = await revealed();
helpers.showView("main");
expect(node("export-privkey-value").textContent).toBe("");
});
});
describe("views the popup may reopen onto", () => {
// Restoring onto this screen would put a private key on display with no
// password prompt in front of it, on a popup reopened by accident.
test("the private key export screen is not restorable", () => {
expect(RESTORABLE_VIEWS.has(VIEW)).toBe(false);
});
test("it is still a registered view", () => {
const { helpers } = load();
expect(helpers.VIEWS).toContain(VIEW);
});
});
describe("the key cannot reach the logger", () => {
const fs = require("fs");
const path = require("path");
const source = fs.readFileSync(
path.join(__dirname, "..", "src", "popup", "views", "exportPrivkey.js"),
"utf8",
);
test("the view does not import src/shared/log.js", () => {
expect(source).not.toMatch(/require\(["'][^"']*shared\/log["']\)/);
});
test("the view calls no logger method", () => {
expect(source).not.toMatch(/\blog\.(debugf|infof|warnf|errorf)\b/);
});
});

View File

@@ -1,17 +1,24 @@
// Provide a localStorage mock for Node.js test environment. // Extension storage stub for the Node test environment. The module resolves
// Must be set before requiring the module since it calls loadDeltaFromStorage() // the storage API on use, so this only has to exist before the first call.
// at module load time. // Values round-trip through JSON the way structured cloning would, so a test
const localStorageStore = {}; // cannot pass by holding a live reference to the module's own array.
global.localStorage = { const storageStore = {};
getItem: (key) => global.chrome = {
Object.prototype.hasOwnProperty.call(localStorageStore, key) storage: {
? localStorageStore[key] local: {
: null, get: async (key) =>
setItem: (key, value) => { Object.prototype.hasOwnProperty.call(storageStore, key)
localStorageStore[key] = String(value); ? { [key]: JSON.parse(JSON.stringify(storageStore[key])) }
: {},
set: async (items) => {
for (const [key, value] of Object.entries(items)) {
storageStore[key] = JSON.parse(JSON.stringify(value));
}
},
remove: async (key) => {
delete storageStore[key];
},
}, },
removeItem: (key) => {
delete localStorageStore[key];
}, },
}; };
@@ -21,19 +28,32 @@ const {
getBlocklistSize, getBlocklistSize,
getDeltaSize, getDeltaSize,
hostnameVariants, hostnameVariants,
DELTA_STORAGE_KEY,
_reset, _reset,
_getVendoredBlacklistSize, _getVendoredBlacklistSize,
_getDeltaBlacklist, _getDeltaBlacklist,
} = require("../src/shared/phishingDomains"); } = require("../src/shared/phishingDomains");
function clearStorage() {
for (const key of Object.keys(storageStore)) {
delete storageStore[key];
}
}
// The MV3 service worker is torn down when idle and re-evaluated on the next
// event, which wipes every module-level variable. Re-requiring the module with
// the registry reset is exactly that: fresh in-memory state, same extension
// storage underneath.
function restartWorker() {
jest.resetModules();
return require("../src/shared/phishingDomains");
}
// Reset delta state before each test to avoid cross-test contamination. // Reset delta state before each test to avoid cross-test contamination.
// Note: vendored sets are immutable and always present. // Note: vendored sets are immutable and always present.
beforeEach(() => { beforeEach(() => {
_reset(); _reset();
// Clear localStorage mock between tests clearStorage();
for (const key of Object.keys(localStorageStore)) {
delete localStorageStore[key];
}
}); });
describe("phishingDomains", () => { describe("phishingDomains", () => {
@@ -169,15 +189,34 @@ describe("phishingDomains", () => {
}); });
}); });
describe("localStorage persistence", () => { describe("extension storage persistence", () => {
test("saveDeltaToStorage persists delta under 256KiB", () => { test("delta is persisted to extension storage, not localStorage", async () => {
loadConfig({ await loadConfig({
blacklist: ["persisted-scam-xyz.com"], blacklist: ["persisted-scam-xyz.com"],
}); });
const stored = localStorage.getItem("phishing-delta"); const stored = storageStore[DELTA_STORAGE_KEY];
expect(stored).not.toBeNull(); expect(stored).toBeDefined();
const data = JSON.parse(stored); expect(stored.blacklist).toContain("persisted-scam-xyz.com");
expect(data.blacklist).toContain("persisted-scam-xyz.com"); });
test("the fetch timestamp is persisted alongside the delta", async () => {
const before = Date.now();
await loadConfig({ blacklist: ["timestamped-scam-xyz.com"] });
const stored = storageStore[DELTA_STORAGE_KEY];
expect(typeof stored.lastFetchTime).toBe("number");
expect(stored.lastFetchTime).toBeGreaterThanOrEqual(before);
});
test("an oversized delta is dropped entirely, timestamp included", async () => {
// A record above the 256 KiB cap is not worth keeping; the
// timestamp goes with it so the next start re-fetches rather than
// claiming freshness for a delta that was never stored.
const huge = [];
for (let i = 0; i < 20000; i++) {
huge.push(`oversize-scam-${i}-xyzxyzxyzxyzxyz.com`);
}
await loadConfig({ blacklist: huge });
expect(storageStore[DELTA_STORAGE_KEY]).toBeUndefined();
}); });
test("delta is cleared on _reset", () => { test("delta is cleared on _reset", () => {
@@ -203,3 +242,332 @@ describe("phishingDomains", () => {
}); });
}); });
}); });
describe("phishing list across a service worker restart", () => {
beforeEach(() => {
clearStorage();
jest.resetModules();
});
afterEach(() => {
delete global.fetch;
});
test("a revived worker restores the persisted delta without re-fetching", async () => {
const first = require("../src/shared/phishingDomains");
await first.loadConfig({ blacklist: ["restart-scam-xyz.com"] });
const revived = restartWorker();
// Nothing in memory yet — this is a brand new module instance.
expect(revived.getDeltaSize()).toBe(0);
global.fetch = jest.fn();
await revived.initPhishingList();
expect(global.fetch).not.toHaveBeenCalled();
expect(revived.getDeltaSize()).toBe(1);
expect(revived.isPhishingDomain("restart-scam-xyz.com")).toBe(true);
});
test("repeated wakes inside the cache window never re-fetch", async () => {
const first = require("../src/shared/phishingDomains");
await first.loadConfig({ blacklist: ["no-storm-scam-xyz.com"] });
global.fetch = jest.fn();
for (let i = 0; i < 5; i++) {
const revived = restartWorker();
await revived.initPhishingList();
}
expect(global.fetch).not.toHaveBeenCalled();
});
test("a persisted timestamp older than the TTL causes a fetch on startup", async () => {
const first = require("../src/shared/phishingDomains");
await first.loadConfig({ blacklist: ["stale-scam-xyz.com"] });
// Age the persisted record past the 24-hour TTL.
storageStore[first.DELTA_STORAGE_KEY].lastFetchTime =
Date.now() - first.CACHE_TTL_MS - 1000;
const revived = restartWorker();
global.fetch = jest.fn(async () => ({
ok: true,
json: async () => ({ blacklist: ["refreshed-scam-xyz.com"] }),
}));
await revived.initPhishingList();
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(revived.isPhishingDomain("refreshed-scam-xyz.com")).toBe(true);
expect(revived.isPhishingDomain("stale-scam-xyz.com")).toBe(false);
});
test("a first start with nothing persisted fetches immediately", async () => {
const fresh = restartWorker();
global.fetch = jest.fn(async () => ({
ok: true,
json: async () => ({ blacklist: ["first-run-scam-xyz.com"] }),
}));
await fresh.initPhishingList();
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(fresh.isPhishingDomain("first-run-scam-xyz.com")).toBe(true);
});
test("updatePhishingList honours the persisted timestamp on its own", async () => {
// The startup path calls updatePhishingList() directly, so it must
// load persisted state itself rather than relying on anything else
// having finished first.
const first = require("../src/shared/phishingDomains");
await first.loadConfig({ blacklist: ["alarm-tick-scam-xyz.com"] });
const revived = restartWorker();
global.fetch = jest.fn();
await revived.updatePhishingList();
expect(global.fetch).not.toHaveBeenCalled();
expect(revived.isPhishingDomain("alarm-tick-scam-xyz.com")).toBe(true);
});
});
// The alarm period alone must set the cadence. lastFetchTime is stamped when
// the fetch completes, so it lands one fetch latency after the alarm that
// caused it; a freshness guard timed to the alarm period therefore vetoes
// every scheduled tick and halves the real refresh rate. These tests measure
// the interval between fetches that actually happened.
describe("phishing refresh steady-state cadence", () => {
const { PHISHING_REFRESH_PERIOD_MINUTES } = require("../src/shared/alarms");
const PERIOD_MS = PHISHING_REFRESH_PERIOD_MINUTES * 60 * 1000;
let clockSpy;
let now;
beforeEach(() => {
clearStorage();
jest.resetModules();
now = Date.UTC(2026, 0, 1, 0, 0, 0);
clockSpy = jest.spyOn(Date, "now").mockImplementation(() => now);
});
afterEach(() => {
clockSpy.mockRestore();
delete global.fetch;
});
function fetchStub(latencyMs, seen) {
return jest.fn(async () => {
seen.push(now);
// A network fetch takes time, and lastFetchTime is stamped after
// it, not when the alarm fired.
now += latencyMs;
return { ok: true, json: async () => ({ blacklist: [] }) };
});
}
test("ten alarm ticks produce ten fetches, one per period", async () => {
const fetchedAt = [];
global.fetch = fetchStub(5000, fetchedAt);
const startup = require("../src/shared/phishingDomains");
const T0 = now;
await startup.initPhishingList();
expect(fetchedAt).toEqual([T0]);
const TICKS = 10;
let tickAt = T0 + PERIOD_MS;
for (let i = 0; i < TICKS; i++) {
now = tickAt;
tickAt += PERIOD_MS;
// The browser wakes a terminated worker to deliver the alarm, so
// every tick starts from cold memory and the persisted record.
const revived = restartWorker();
await revived.refreshPhishingListOnSchedule();
}
expect(fetchedAt).toHaveLength(TICKS + 1);
const intervals = fetchedAt.slice(1).map((t, i) => t - fetchedAt[i]);
expect(intervals).toEqual(new Array(TICKS).fill(PERIOD_MS));
});
test("the scheduled tick fetches whatever the last fetch's latency was", async () => {
// The alarm fires one period after the previous alarm, which is
// `latency` short of one period since the fetch it caused completed.
for (const latency of [200, 1000, 5000]) {
clearStorage();
jest.resetModules();
storageStore[DELTA_STORAGE_KEY] = {
blacklist: [],
lastFetchTime: now - PERIOD_MS + latency,
lastAttemptTime: now - PERIOD_MS,
};
const mod = require("../src/shared/phishingDomains");
const fetchedAt = [];
global.fetch = fetchStub(latency, fetchedAt);
await mod.refreshPhishingListOnSchedule();
expect(fetchedAt).toHaveLength(1);
}
});
test("a worker wake inside the cache window still does not fetch", async () => {
// The TTL is not removed, only taken off the scheduled path. Chrome
// revives the worker every ~30 seconds and every revival runs the
// startup path, so the TTL still has to keep that off the network.
storageStore[DELTA_STORAGE_KEY] = {
blacklist: [],
lastFetchTime: now - PERIOD_MS + 5000,
lastAttemptTime: now - PERIOD_MS,
};
const mod = require("../src/shared/phishingDomains");
global.fetch = jest.fn();
await mod.initPhishingList();
expect(global.fetch).not.toHaveBeenCalled();
});
});
describe("phishing list timestamps that cannot be trusted", () => {
let clockSpy;
let now;
beforeEach(() => {
clearStorage();
jest.resetModules();
now = Date.UTC(2026, 0, 1, 0, 0, 0);
clockSpy = jest.spyOn(Date, "now").mockImplementation(() => now);
});
afterEach(() => {
clockSpy.mockRestore();
delete global.fetch;
});
function okFetch() {
return jest.fn(async () => ({
ok: true,
json: async () => ({ blacklist: ["recovered-scam-xyz.com"] }),
}));
}
// jest.resetModules() clears the call record of a jest.fn, and simulating
// a worker restart is exactly that call. Anything counted across restarts
// has to be counted outside the mock.
function countingFetch(counter, response) {
return async () => {
counter.calls++;
return response();
};
}
test("a lastFetchTime in the future is discarded rather than trusted", async () => {
// Clock skew or a restored profile backup writes one. Every guard
// measures `Date.now() - stamp` and only tests the lower bound, so a
// stamp a year ahead would suppress updates for a year, and now that
// the value is persisted it would outlive every worker.
storageStore[DELTA_STORAGE_KEY] = {
blacklist: ["poisoned-scam-xyz.com"],
lastFetchTime: now + 365 * 24 * 60 * 60 * 1000,
lastAttemptTime: 0,
};
const mod = require("../src/shared/phishingDomains");
global.fetch = okFetch();
await mod.initPhishingList();
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(mod.isPhishingDomain("recovered-scam-xyz.com")).toBe(true);
// And the record it leaves behind is sane, so recovery is permanent.
expect(
storageStore[DELTA_STORAGE_KEY].lastFetchTime,
).toBeLessThanOrEqual(now);
});
test("a lastAttemptTime in the future does not suppress the retry", async () => {
storageStore[DELTA_STORAGE_KEY] = {
lastAttemptTime: now + 365 * 24 * 60 * 60 * 1000,
};
const mod = require("../src/shared/phishingDomains");
global.fetch = okFetch();
await mod.initPhishingList();
expect(global.fetch).toHaveBeenCalledTimes(1);
});
test("an oversized delta does not re-download on every worker wake", async () => {
// The delta and its freshness claim are both dropped, which is right,
// but nothing then says a fetch just happened. Chrome cycles the
// worker roughly every 30 seconds idle, so without the attempt stamp
// this is a full blocklist download per wake, forever.
const huge = [];
for (let i = 0; i < 20000; i++) {
huge.push(`oversize-scam-${i}-xyzxyzxyzxyzxyz.com`);
}
const counter = { calls: 0 };
global.fetch = countingFetch(counter, () => ({
ok: true,
json: async () => ({ blacklist: huge }),
}));
for (let wake = 0; wake < 4; wake++) {
const revived = restartWorker();
await revived.initPhishingList();
now += 30 * 1000; // idle timeout, worker torn down and revived
}
expect(counter.calls).toBe(1);
expect(storageStore[DELTA_STORAGE_KEY].blacklist).toBeUndefined();
expect(typeof storageStore[DELTA_STORAGE_KEY].lastAttemptTime).toBe(
"number",
);
});
test("a failing fetch is not retried on every worker wake either", async () => {
const counter = { calls: 0 };
global.fetch = countingFetch(counter, () => ({
ok: false,
status: 503,
}));
for (let wake = 0; wake < 4; wake++) {
const revived = restartWorker();
await revived.initPhishingList();
now += 30 * 1000;
}
expect(counter.calls).toBe(1);
});
test("the retry floor expires, so a failure is not permanent", async () => {
const {
MIN_FETCH_ATTEMPT_INTERVAL_MS,
} = require("../src/shared/phishingDomains");
const counter = { calls: 0 };
global.fetch = countingFetch(counter, () => ({
ok: false,
status: 503,
}));
await restartWorker().initPhishingList();
expect(counter.calls).toBe(1);
// Still inside the floor: no retry.
now += MIN_FETCH_ATTEMPT_INTERVAL_MS - 1000;
await restartWorker().initPhishingList();
expect(counter.calls).toBe(1);
// Past it: the extension goes back to the network.
now += 2000;
await restartWorker().initPhishingList();
expect(counter.calls).toBe(2);
});
test("the scheduled tick ignores the retry floor", async () => {
// The alarm period is far above the floor, but the floor exists to
// throttle wakes, not the schedule.
storageStore[DELTA_STORAGE_KEY] = { lastAttemptTime: now - 1000 };
const mod = require("../src/shared/phishingDomains");
global.fetch = okFetch();
await mod.refreshPhishingListOnSchedule();
expect(global.fetch).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,111 @@
// Tests for the UTC Timestamps setting.
//
// The checkbox was moved out of the Token Spam Protection well and into the
// Display well next to the theme selector. It is wired by id through the $()
// helper, so the move cannot break the handler — but nothing in the suite said
// so. These tests pin both halves down: the markup lives in Display and
// nowhere else, and the value still round-trips through storage.
const fs = require("fs");
const path = require("path");
const POPUP_HTML = fs.readFileSync(
path.join(__dirname, "..", "src", "popup", "index.html"),
"utf8",
);
// The body of one `<div class="bg-well ...">` well, selected by its heading.
function wellWithHeading(html, heading) {
const headingIndex = html.indexOf(
'<h3 class="font-bold mb-1">' + heading + "</h3>",
);
expect(headingIndex).toBeGreaterThan(-1);
const start = html.lastIndexOf('<div class="bg-well', headingIndex);
const end = html.indexOf('<div class="bg-well', headingIndex);
return html.slice(start, end === -1 ? html.length : end);
}
describe("the UTC Timestamps checkbox placement", () => {
test("the checkbox appears exactly once in the popup markup", () => {
const matches = POPUP_HTML.match(/id="settings-utc-timestamps"/g);
expect(matches).toHaveLength(1);
});
test("it renders in the Display well, alongside the theme selector", () => {
const display = wellWithHeading(POPUP_HTML, "Display");
expect(display).toContain('id="settings-utc-timestamps"');
expect(display).toContain('id="settings-theme"');
});
test("it does not render in the Token Spam Protection well", () => {
const spam = wellWithHeading(POPUP_HTML, "Token Spam Protection");
expect(spam).not.toContain('id="settings-utc-timestamps"');
// The filters that do belong there are untouched.
expect(spam).toContain('id="settings-hide-low-holders"');
expect(spam).toContain('id="settings-hide-fraud-contracts"');
expect(spam).toContain('id="settings-hide-dust"');
expect(spam).toContain('id="settings-dust-threshold"');
});
});
describe("the UTC Timestamps setting round-trips through storage", () => {
let store;
function loadStateModule() {
store = {};
global.chrome = {
storage: {
local: {
get: async (key) =>
key in store ? { [key]: store[key] } : {},
set: async (obj) => Object.assign(store, obj),
},
},
};
jest.resetModules();
return require("../src/shared/state");
}
afterEach(() => {
delete global.chrome;
});
test("defaults to off with nothing persisted", async () => {
const { state, loadState } = loadStateModule();
await loadState();
expect(state.utcTimestamps).toBe(false);
});
test("an enabled checkbox is persisted and read back", async () => {
const first = loadStateModule();
// What the change handler in views/settings.js does.
first.state.utcTimestamps = true;
await first.saveState();
expect(store.autistmask.utcTimestamps).toBe(true);
// A fresh popup load sees it.
jest.resetModules();
const second = require("../src/shared/state");
expect(second.state.utcTimestamps).toBe(false);
await second.loadState();
expect(second.state.utcTimestamps).toBe(true);
});
test("turning it back off is persisted too", async () => {
const { state, saveState, loadState } = loadStateModule();
state.utcTimestamps = true;
await saveState();
state.utcTimestamps = false;
await saveState();
state.utcTimestamps = true;
await loadState();
expect(state.utcTimestamps).toBe(false);
});
});

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

@@ -0,0 +1,94 @@
// Tests for the recovery phrase display (issue #161).
//
// These cover the parts that do not need a DOM: which wallet types may be
// offered the action at all, the exclusion of the screen from the set of
// views the popup may reopen onto, and the absence of any path from this
// module to the logger. The DOM behaviour it guards — nothing rendered
// before the password is accepted, a wrong password revealing nothing, and
// the wipe on leaving — is driven against the real popup in a real browser
// by tests/e2e/run.js, which is where every other view behaviour is tested.
const fs = require("fs");
const path = require("path");
const { walletHasRecoveryPhrase } = require("../src/shared/wallet");
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
const SHOW_PHRASE_VIEW = "show-phrase";
// helpers.js pulls in state.js, which reads chrome.storage.local at load.
function loadHelpers() {
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
return require("../src/popup/views/helpers");
}
describe("which wallets have a recovery phrase", () => {
test("an HD wallet does", () => {
expect(walletHasRecoveryPhrase({ type: "hd" })).toBe(true);
});
// A key wallet holds a bare private key and an xprv wallet an extended
// private key. Neither can be turned back into words, so neither may be
// offered the action.
test("a key wallet does not", () => {
expect(walletHasRecoveryPhrase({ type: "key" })).toBe(false);
});
test("an xprv wallet does not", () => {
expect(walletHasRecoveryPhrase({ type: "xprv" })).toBe(false);
});
test("an unknown or missing wallet type does not", () => {
expect(walletHasRecoveryPhrase({ type: "something-new" })).toBe(false);
expect(walletHasRecoveryPhrase({})).toBe(false);
expect(walletHasRecoveryPhrase(undefined)).toBe(false);
});
});
describe("views the popup may reopen onto", () => {
// Restoring onto a secret screen would put the phrase on screen with no
// password prompt in front of it, on a popup the user may have reopened
// by accident.
test("the recovery phrase screen is not restorable", () => {
expect(RESTORABLE_VIEWS.has(SHOW_PHRASE_VIEW)).toBe(false);
});
test("the private key export screen is not restorable either", () => {
expect(RESTORABLE_VIEWS.has("export-privkey")).toBe(false);
});
test("the recovery phrase screen is still a registered view", () => {
const { VIEWS } = loadHelpers();
expect(VIEWS).toContain(SHOW_PHRASE_VIEW);
});
// Guards the other direction: a restorable name that is not a real view
// would leave restoreView() showing nothing at all.
test("every restorable view is a registered view", () => {
const { VIEWS } = loadHelpers();
for (const view of RESTORABLE_VIEWS) {
expect(VIEWS).toContain(view);
}
});
});
describe("the phrase cannot reach the logger", () => {
const source = fs.readFileSync(
path.join(__dirname, "..", "src", "popup", "views", "showPhrase.js"),
"utf8",
);
// The decrypted phrase only ever lives in a local and in the DOM node
// that displays it. The module has no logger to hand it to, and this
// pins that: src/shared/log.js writes to the console, and a console
// record of a recovery phrase outlives the popup.
test("the view does not import src/shared/log.js", () => {
expect(source).not.toMatch(/require\(["'][^"']*shared\/log["']\)/);
});
test("the view calls no logger method", () => {
expect(source).not.toMatch(/\blog\.(debugf|infof|warnf|errorf)\b/);
});
});

161
tests/state.test.js Normal file
View File

@@ -0,0 +1,161 @@
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
function oneWallet() {
return [{ name: "Wallet 1", type: "hd", addresses: [ADDRESS] }];
}
// state.js resolves the storage API at require time, so the stub has to exist
// before the module is loaded, and the module registry has to be reset between
// cases because `state` is a module-level singleton.
function loadModuleWith(persisted) {
jest.resetModules();
const set = jest.fn(async () => {});
global.chrome = {
storage: {
local: {
get: jest.fn(async () =>
persisted ? { autistmask: persisted } : {},
),
set,
},
},
};
return { mod: require("../src/shared/state"), set };
}
afterEach(() => {
delete global.chrome;
});
describe("loadState hasWallet reconciliation", () => {
// A profile that deleted its last wallet on a build predating the write
// path fix keeps hasWallet: true forever. It must load as no wallet, which
// is what sends the popup to the welcome view.
test("stored hasWallet true with zero wallets loads as no wallet", async () => {
const { mod } = loadModuleWith({ hasWallet: true, wallets: [] });
await mod.loadState();
expect(mod.state.hasWallet).toBe(false);
});
test("stored hasWallet true with a missing wallets key loads as no wallet", async () => {
const { mod } = loadModuleWith({ hasWallet: true });
await mod.loadState();
expect(mod.state.wallets).toEqual([]);
expect(mod.state.hasWallet).toBe(false);
});
test("stored hasWallet false with one wallet loads as having a wallet", async () => {
const { mod } = loadModuleWith({
hasWallet: false,
wallets: oneWallet(),
});
await mod.loadState();
expect(mod.state.hasWallet).toBe(true);
});
test("absent hasWallet with wallets present loads as having a wallet", async () => {
const { mod } = loadModuleWith({ wallets: oneWallet() });
await mod.loadState();
expect(mod.state.hasWallet).toBe(true);
});
test("consistent stored states are preserved", async () => {
const withWallet = loadModuleWith({
hasWallet: true,
wallets: oneWallet(),
});
await withWallet.mod.loadState();
expect(withWallet.mod.state.hasWallet).toBe(true);
const without = loadModuleWith({ hasWallet: false, wallets: [] });
await without.mod.loadState();
expect(without.mod.state.hasWallet).toBe(false);
});
test("empty storage leaves the default no-wallet state", async () => {
const { mod } = loadModuleWith(null);
await mod.loadState();
expect(mod.state.hasWallet).toBe(false);
expect(mod.state.wallets).toEqual([]);
});
// The correction is derived on every load rather than written back, so a
// load never has a storage side effect.
test("loadState does not write to storage", async () => {
const { mod, set } = loadModuleWith({ hasWallet: true, wallets: [] });
await mod.loadState();
expect(set).not.toHaveBeenCalled();
});
// Deriving must not disturb the rest of the load.
test("other persisted fields still load", async () => {
const { mod } = loadModuleWith({
hasWallet: false,
wallets: oneWallet(),
networkId: "sepolia",
theme: "dark",
activeAddress: ADDRESS,
});
await mod.loadState();
expect(mod.state.networkId).toBe("sepolia");
expect(mod.state.theme).toBe("dark");
expect(mod.state.activeAddress).toBe(ADDRESS);
});
});
// The known-symbol spoof filter is a safety filter, so an existing profile
// stored before the setting existed must load with it on rather than with
// undefined, which would read as off.
describe("hideSpoofedSymbols persistence", () => {
test("defaults to on with empty storage", async () => {
const { mod } = loadModuleWith(null);
await mod.loadState();
expect(mod.state.hideSpoofedSymbols).toBe(true);
});
test("a profile stored without the key loads with it on", async () => {
const { mod } = loadModuleWith({ wallets: oneWallet() });
await mod.loadState();
expect(mod.state.hideSpoofedSymbols).toBe(true);
});
test("an explicit false survives the load", async () => {
const { mod } = loadModuleWith({
wallets: oneWallet(),
hideSpoofedSymbols: false,
});
await mod.loadState();
expect(mod.state.hideSpoofedSymbols).toBe(false);
});
test("saveState persists the flag", async () => {
const { mod, set } = loadModuleWith(null);
mod.state.hideSpoofedSymbols = false;
await mod.saveState();
expect(set).toHaveBeenCalledWith({
autistmask: expect.objectContaining({ hideSpoofedSymbols: false }),
});
});
test("the flag round-trips off through save and load", async () => {
const first = loadModuleWith(null);
first.mod.state.hideSpoofedSymbols = false;
await first.mod.saveState();
const persisted = first.set.mock.calls[0][0].autistmask;
const second = loadModuleWith(persisted);
await second.mod.loadState();
expect(second.mod.state.hideSpoofedSymbols).toBe(false);
});
test("the flag round-trips back on through save and load", async () => {
const first = loadModuleWith(null);
first.mod.state.hideSpoofedSymbols = true;
await first.mod.saveState();
const persisted = first.set.mock.calls[0][0].autistmask;
const second = loadModuleWith(persisted);
await second.mod.loadState();
expect(second.mod.state.hideSpoofedSymbols).toBe(true);
});
});

1493
tests/transactions.test.js Normal file

File diff suppressed because it is too large Load Diff

357
tests/txValidation.test.js Normal file
View File

@@ -0,0 +1,357 @@
const { parseEther } = require("ethers");
const {
CODES,
FEE_PENDING,
FEE_KNOWN,
FEE_UNAVAILABLE,
feeReserveWei,
feeEstimateWei,
toFixedPoint,
validateTransfer,
} = require("../src/shared/txValidation");
// A plausible mainnet fee: 21000 gas at 20 gwei.
const FEE = 21000n * 20000000000n; // 0.00042 ETH
const GWEI = 1000000000n;
const GAS_LIMIT = 21000n;
describe("toFixedPoint", () => {
test("scales human decimals to 18 places", () => {
expect(toFixedPoint("1.5")).toBe(parseEther("1.5"));
expect(toFixedPoint("0")).toBe(0n);
});
test("rejects values it cannot represent exactly", () => {
expect(toFixedPoint("not a number")).toBe(null);
expect(toFixedPoint("")).toBe(null);
expect(toFixedPoint(null)).toBe(null);
// More precision than 18 decimals can hold.
expect(toFixedPoint("0.0000000000000000001")).toBe(null);
});
});
describe("validateTransfer, native ETH", () => {
const eth = (over) => ({
isErc20: false,
amount: "0.5",
ethBalance: "1.0",
feeStatus: FEE_KNOWN,
feeWei: FEE,
...over,
});
test("allows a send comfortably within balance", () => {
const r = validateTransfer(eth());
expect(r).toEqual({ canSend: true, codes: [] });
});
test("blocks a send whose amount plus fee exceeds the balance", () => {
// The whole balance: passes an amount-only check, fails once the fee
// is counted. This is the bug this module exists to prevent.
const r = validateTransfer(eth({ amount: "1.0", ethBalance: "1.0" }));
expect(r.canSend).toBe(false);
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH_WITH_FEE]);
});
test("blocks a send left short by less than one fee", () => {
const balance = "1.0";
// One wei less headroom than the fee needs.
const amount = "0.99958000000000001"; // 1.0 - 0.00042 + 1e-17
const r = validateTransfer(eth({ amount, ethBalance: balance }));
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH_WITH_FEE]);
});
test("allows a send that leaves exactly the fee behind", () => {
const r = validateTransfer(
eth({ amount: "0.99958", ethBalance: "1.0" }),
);
expect(r).toEqual({ canSend: true, codes: [] });
});
test("reports plain insufficient balance when the amount alone is too big", () => {
const r = validateTransfer(eth({ amount: "2.0", ethBalance: "1.0" }));
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH]);
});
test("blocks while the fee estimate is still pending", () => {
const r = validateTransfer(
eth({ feeStatus: FEE_PENDING, feeWei: null }),
);
expect(r.canSend).toBe(false);
expect(r.codes).toEqual([CODES.FEE_PENDING]);
});
test("blocks when the fee estimate failed, without assuming zero", () => {
const r = validateTransfer(
eth({
amount: "1.0",
ethBalance: "1.0",
feeStatus: FEE_UNAVAILABLE,
feeWei: null,
}),
);
expect(r.canSend).toBe(false);
expect(r.codes).toEqual([CODES.FEE_UNAVAILABLE]);
// A zero fee would have let this exact transfer through.
expect(
validateTransfer(
eth({ amount: "1.0", ethBalance: "1.0", feeWei: 0n }),
).canSend,
).toBe(true);
});
test("still reports an over-balance amount before the estimate lands", () => {
const r = validateTransfer(
eth({
amount: "2.0",
ethBalance: "1.0",
feeStatus: FEE_PENDING,
feeWei: null,
}),
);
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH, CODES.FEE_PENDING]);
});
test("rejects an amount it cannot do exact arithmetic on", () => {
const r = validateTransfer(eth({ amount: "abc" }));
expect(r.canSend).toBe(false);
expect(r.codes).toEqual([CODES.AMOUNT_INVALID]);
});
test("rejects a negative amount", () => {
// A negative amount parses to a perfectly good bigint, so neither
// balance comparison can fire: both are trivially false against it.
// Left unblocked it clears the screen and then dies at encode time.
const r = validateTransfer(
eth({ amount: "-1", ethBalance: "1.0", feeWei: 861000000000000n }),
);
expect(r).toEqual({ canSend: false, codes: [CODES.AMOUNT_INVALID] });
expect(
validateTransfer(eth({ amount: "-0.000000000000000001" })),
).toEqual({ canSend: false, codes: [CODES.AMOUNT_INVALID] });
});
test("treats a missing balance as zero, not as unlimited", () => {
const r = validateTransfer(eth({ ethBalance: undefined }));
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH]);
});
});
describe("validateTransfer, ERC-20", () => {
const erc20 = (over) => ({
isErc20: true,
amount: "100.0",
tokenBalance: "250.0",
ethBalance: "1.0",
feeStatus: FEE_KNOWN,
feeWei: FEE,
...over,
});
test("allows a transfer with tokens to spend and ETH for the fee", () => {
expect(validateTransfer(erc20())).toEqual({ canSend: true, codes: [] });
});
test("checks the token amount against the token balance", () => {
const r = validateTransfer(erc20({ amount: "250.000001" }));
expect(r.codes).toEqual([CODES.INSUFFICIENT_TOKEN]);
});
test("does not charge the fee against the token balance", () => {
// The full token balance is sendable: the fee is paid in ETH.
expect(validateTransfer(erc20({ amount: "250.0" })).canSend).toBe(true);
});
test("blocks when the ETH balance does not cover the fee", () => {
const r = validateTransfer(erc20({ ethBalance: "0.0001" }));
expect(r.canSend).toBe(false);
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH_FOR_FEE]);
});
test("allows a fee exactly equal to the ETH balance", () => {
const r = validateTransfer(erc20({ ethBalance: "0.00042" }));
expect(r).toEqual({ canSend: true, codes: [] });
});
test("reports both shortfalls when tokens and ETH are both short", () => {
const r = validateTransfer(
erc20({ amount: "300.0", ethBalance: "0.0" }),
);
expect(r.codes).toEqual([
CODES.INSUFFICIENT_TOKEN,
CODES.INSUFFICIENT_ETH_FOR_FEE,
]);
});
test("blocks while the fee estimate is pending or failed", () => {
expect(
validateTransfer(erc20({ feeStatus: FEE_PENDING, feeWei: null }))
.codes,
).toEqual([CODES.FEE_PENDING]);
expect(
validateTransfer(
erc20({ feeStatus: FEE_UNAVAILABLE, feeWei: null }),
).codes,
).toEqual([CODES.FEE_UNAVAILABLE]);
});
test("rejects a negative token amount", () => {
const r = validateTransfer(
erc20({ amount: "-0.5", feeWei: 861000000000000n }),
);
expect(r).toEqual({ canSend: false, codes: [CODES.AMOUNT_INVALID] });
});
test("treats a missing token balance as zero", () => {
const r = validateTransfer(erc20({ tokenBalance: undefined }));
expect(r.codes).toEqual([CODES.INSUFFICIENT_TOKEN]);
});
});
// The reserve a node requires, not the fee the transaction is expected to
// actually cost. An unpinned send goes out as type-2, and the node checks it
// against maxFeePerGas; reserving gasPrice lets a transaction the node will
// reject pass the gate.
describe("feeReserveWei", () => {
// baseFee 20 gwei, tip 1 gwei: eth_gasPrice reports ~21 gwei, while
// ethers populates maxFeePerGas as baseFee * 2 + tip = 41 gwei.
const type2 = {
gasPrice: 21n * GWEI,
maxFeePerGas: 41n * GWEI,
maxPriorityFeePerGas: 1n * GWEI,
};
test("reserves gasLimit * maxFeePerGas, not gasLimit * gasPrice", () => {
expect(feeReserveWei(GAS_LIMIT, type2)).toBe(GAS_LIMIT * 41n * GWEI);
expect(feeReserveWei(GAS_LIMIT, type2)).toBe(861000000000000n);
// The number the node would not have accepted.
expect(feeReserveWei(GAS_LIMIT, type2)).not.toBe(441000000000000n);
});
test("gates out a send the type-2 reserve cannot fund", () => {
// Exactly fundable against a gasPrice reserve (0.999559 + 0.000441 is
// the whole balance to the wei), and short against the reserve the
// node will actually require.
const send = {
isErc20: false,
amount: "0.999559",
ethBalance: "1.0",
feeStatus: FEE_KNOWN,
};
expect(
validateTransfer({
...send,
feeWei: GAS_LIMIT * type2.gasPrice,
}).canSend,
).toBe(true);
const r = validateTransfer({
...send,
feeWei: feeReserveWei(GAS_LIMIT, type2),
});
expect(r.canSend).toBe(false);
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH_WITH_FEE]);
});
test("falls back to gasPrice on a network with no type-2 pricing", () => {
const legacy = { gasPrice: 21n * GWEI, maxFeePerGas: null };
expect(feeReserveWei(GAS_LIMIT, legacy)).toBe(GAS_LIMIT * 21n * GWEI);
});
test("returns null when no usable price or gas limit is available", () => {
expect(feeReserveWei(GAS_LIMIT, { gasPrice: null })).toBe(null);
expect(feeReserveWei(GAS_LIMIT, {})).toBe(null);
expect(feeReserveWei(GAS_LIMIT, null)).toBe(null);
expect(feeReserveWei(21000, type2)).toBe(null);
});
});
// The display counterpart of the reserve: what the transaction is expected to
// cost. Shown alongside the reserve so the screen neither contradicts the gate
// nor quotes the user roughly double what they will pay.
describe("feeEstimateWei", () => {
const type2 = {
gasPrice: 21n * GWEI,
maxFeePerGas: 41n * GWEI,
maxPriorityFeePerGas: 1n * GWEI,
};
test("estimates gasLimit * gasPrice, below the reserve", () => {
expect(feeEstimateWei(GAS_LIMIT, type2)).toBe(441000000000000n);
expect(feeReserveWei(GAS_LIMIT, type2)).toBe(861000000000000n);
expect(feeEstimateWei(GAS_LIMIT, type2)).toBeLessThan(
feeReserveWei(GAS_LIMIT, type2),
);
});
test("equals the reserve when the network has no type-2 pricing", () => {
const legacy = { gasPrice: 21n * GWEI, maxFeePerGas: null };
expect(feeEstimateWei(GAS_LIMIT, legacy)).toBe(
feeReserveWei(GAS_LIMIT, legacy),
);
});
test("falls back to maxFeePerGas when there is no gasPrice", () => {
const noLegacy = { gasPrice: null, maxFeePerGas: 41n * GWEI };
expect(feeEstimateWei(GAS_LIMIT, noLegacy)).toBe(
feeReserveWei(GAS_LIMIT, noLegacy),
);
});
test("returns null on the same unusable inputs as the reserve", () => {
expect(feeEstimateWei(GAS_LIMIT, {})).toBe(null);
expect(feeEstimateWei(GAS_LIMIT, null)).toBe(null);
expect(feeEstimateWei(GAS_LIMIT, { gasPrice: -1n })).toBe(null);
expect(feeEstimateWei(21000, type2)).toBe(null);
});
});
// Everything that is not a usable fee blocks exactly as FEE_UNAVAILABLE does.
// Each of these previously returned { canSend: true, codes: [] } — counting no
// fee at all, on a full-balance send, in the direction that lets money out.
describe("validateTransfer, unusable fee input fails closed", () => {
const fullBalanceSend = (over) => ({
isErc20: false,
amount: "1.0",
ethBalance: "1.0",
...over,
});
test("blocks a null fee claiming to be known", () => {
const r = validateTransfer(
fullBalanceSend({ feeStatus: FEE_KNOWN, feeWei: null }),
);
expect(r).toEqual({ canSend: false, codes: [CODES.FEE_UNAVAILABLE] });
});
test("blocks a known fee that is a number rather than a bigint", () => {
const r = validateTransfer(
fullBalanceSend({ feeStatus: FEE_KNOWN, feeWei: 420000000000000 }),
);
expect(r).toEqual({ canSend: false, codes: [CODES.FEE_UNAVAILABLE] });
});
test("blocks an unrecognised fee status", () => {
const r = validateTransfer(fullBalanceSend({ feeStatus: "bogus" }));
expect(r).toEqual({ canSend: false, codes: [CODES.FEE_UNAVAILABLE] });
});
test("blocks a negative fee", () => {
const r = validateTransfer(
fullBalanceSend({ feeStatus: FEE_KNOWN, feeWei: -1n }),
);
expect(r).toEqual({ canSend: false, codes: [CODES.FEE_UNAVAILABLE] });
});
test("blocks an ERC-20 transfer on an unusable fee too", () => {
const r = validateTransfer({
isErc20: true,
amount: "100.0",
tokenBalance: "250.0",
ethBalance: "1.0",
feeStatus: FEE_KNOWN,
feeWei: null,
});
expect(r).toEqual({ canSend: false, codes: [CODES.FEE_UNAVAILABLE] });
});
});

346
tests/vault.test.js Normal file
View File

@@ -0,0 +1,346 @@
// Tests for src/shared/vault.js: the Argon2id + XSalsa20-Poly1305 encryption
// that protects recovery phrases and private keys at rest.
//
// The properties that matter here are the ones whose failure is silent. A
// vault that decrypts under the wrong password, that hands back plaintext from
// a ciphertext an attacker edited, that reuses a nonce, or that leaves the
// recovery phrase readable somewhere in the stored blob all look exactly like
// a working vault from the UI. So each test below asserts a negative: the
// thing that must not happen.
//
// Cost: every encrypt and decrypt runs one Argon2id pwhash at the production
// interactive parameters, which the module hardcodes. The parameters are not
// weakened or overridden anywhere in this file — they are pinned by the "key
// derivation cost" tests, since they are the vault's only defence against an
// offline attack on a stolen blob. The suite is kept inside script/test's
// 30-second budget by sharing one encrypted fixture across the tamper cases
// instead of re-encrypting per test.
const sodium = require("libsodium-wrappers-sumo");
const {
encryptWithPassword,
decryptWithPassword,
} = require("../src/shared/vault");
// A publicly known development phrase. Never fund it.
const SECRET = "test test test test test test test test test test test junk";
const PASSWORD = "correct horse battery staple";
const WRONG_PASSWORD = "correct horse battery stapl";
const SALT_BYTES = 16;
const NONCE_BYTES = 24;
const POLY1305_TAG_BYTES = 16;
const BASE64 = /^[A-Za-z0-9+/_-]+={0,2}$/;
function b64decode(s) {
return sodium.from_base64(s);
}
// A shallow copy with one field replaced, so the shared fixture is never
// mutated by a tamper test.
function withField(blob, field, value) {
return { ...blob, [field]: value };
}
// Flip the low bit of one byte of a base64-encoded field.
function flipByte(b64, index) {
const bytes = b64decode(b64);
bytes[index] ^= 0x01;
return sodium.to_base64(bytes);
}
let vault;
beforeAll(async () => {
await sodium.ready;
vault = await encryptWithPassword(SECRET, PASSWORD);
});
describe("stored blob shape", () => {
test("is exactly the documented { salt, nonce, ciphertext }", () => {
expect(Object.keys(vault).sort()).toEqual([
"ciphertext",
"nonce",
"salt",
]);
});
test("every field is a base64 string", () => {
for (const field of ["salt", "nonce", "ciphertext"]) {
expect(typeof vault[field]).toBe("string");
expect(vault[field]).toMatch(BASE64);
}
});
test("salt and nonce are full length", () => {
expect(b64decode(vault.salt)).toHaveLength(SALT_BYTES);
expect(b64decode(vault.nonce)).toHaveLength(NONCE_BYTES);
});
test("ciphertext carries a Poly1305 authentication tag", () => {
expect(b64decode(vault.ciphertext)).toHaveLength(
SECRET.length + POLY1305_TAG_BYTES,
);
});
test("the blob survives JSON storage unchanged", async () => {
const stored = JSON.parse(JSON.stringify(vault));
await expect(decryptWithPassword(stored, PASSWORD)).resolves.toBe(
SECRET,
);
});
});
describe("no plaintext leakage", () => {
test("the secret does not appear in the serialized vault", () => {
const serialized = JSON.stringify(vault);
expect(serialized).not.toContain(SECRET);
for (const word of new Set(SECRET.split(" "))) {
expect(serialized).not.toContain(word);
}
});
test("the ciphertext bytes do not contain the secret bytes", () => {
const bytes = Buffer.from(b64decode(vault.ciphertext));
expect(bytes.includes(Buffer.from(SECRET, "utf8"))).toBe(false);
// Not even the first word, which would betray an unencrypted prefix.
expect(bytes.includes(Buffer.from("test test", "utf8"))).toBe(false);
});
test("the password does not appear in the serialized vault", () => {
expect(JSON.stringify(vault)).not.toContain(PASSWORD);
});
});
describe("round trip", () => {
test("decrypts back to the original secret", async () => {
await expect(decryptWithPassword(vault, PASSWORD)).resolves.toBe(
SECRET,
);
});
test("survives a non-ASCII plaintext byte for byte", async () => {
const unicode = "recovery phrase é中文\u{1f600}";
const blob = await encryptWithPassword(unicode, PASSWORD);
await expect(decryptWithPassword(blob, PASSWORD)).resolves.toBe(
unicode,
);
});
test("an empty password still round-trips and is not a bypass", async () => {
const blob = await encryptWithPassword(SECRET, "");
await expect(decryptWithPassword(blob, "")).resolves.toBe(SECRET);
// An empty password must not act as a skeleton key on other vaults,
// nor may a real password open an empty-password vault.
await expect(decryptWithPassword(vault, "")).rejects.toThrow();
await expect(decryptWithPassword(blob, PASSWORD)).rejects.toThrow();
});
});
describe("fresh salt and nonce", () => {
test("two encryptions of the same plaintext differ in all three fields", async () => {
const second = await encryptWithPassword(SECRET, PASSWORD);
expect(second.salt).not.toBe(vault.salt);
expect(second.nonce).not.toBe(vault.nonce);
expect(second.ciphertext).not.toBe(vault.ciphertext);
await expect(decryptWithPassword(second, PASSWORD)).resolves.toBe(
SECRET,
);
});
});
describe("key derivation cost", () => {
// Argon2id's opslimit and memlimit are the whole of the vault's resistance
// to an offline attack on a stolen blob, and lowering them breaks nothing
// any other test here can see — the suite merely runs faster. So pin them
// directly, both to libsodium's INTERACTIVE constants and to the absolute
// values those constants must keep meaning.
const INTERACTIVE_OPSLIMIT = 2;
const INTERACTIVE_MEMLIMIT = 64 * 1024 * 1024;
test("the interactive constants still mean 2 passes over 64 MiB", () => {
expect(sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE).toBe(
INTERACTIVE_OPSLIMIT,
);
expect(sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE).toBe(
INTERACTIVE_MEMLIMIT,
);
// The floor these must never quietly be swapped for: _MIN is one pass
// over 8 KiB, an 8192x reduction in memory cost.
expect(sodium.crypto_pwhash_OPSLIMIT_MIN).toBeLessThan(
INTERACTIVE_OPSLIMIT,
);
expect(sodium.crypto_pwhash_MEMLIMIT_MIN).toBeLessThan(
INTERACTIVE_MEMLIMIT,
);
});
test("a key derived at the interactive parameters opens the vault", () => {
// Independent of any spy, and of the module's own code path: derive
// the key here from the vault's published salt at the interactive cost
// and open its ciphertext directly. A vault whose key came from any
// other opslimit, memlimit or Argon2id variant yields a different key
// and cannot be opened this way.
const key = sodium.crypto_pwhash(
sodium.crypto_secretbox_KEYBYTES,
PASSWORD,
b64decode(vault.salt),
INTERACTIVE_OPSLIMIT,
INTERACTIVE_MEMLIMIT,
sodium.crypto_pwhash_ALG_ARGON2ID13,
);
const opened = sodium.crypto_secretbox_open_easy(
b64decode(vault.ciphertext),
b64decode(vault.nonce),
key,
);
expect(sodium.to_string(opened)).toBe(SECRET);
});
test.each([
[
"encrypt",
async () => {
await encryptWithPassword(SECRET, PASSWORD);
},
],
[
"decrypt",
async () => {
await decryptWithPassword(vault, PASSWORD);
},
],
])("%s derives exactly one key at the interactive cost", async (_, run) => {
const spy = jest.spyOn(sodium, "crypto_pwhash");
try {
await run();
expect(spy).toHaveBeenCalledTimes(1);
const [keyBytes, , salt, opslimit, memlimit, alg] =
spy.mock.calls[0];
expect(keyBytes).toBe(sodium.crypto_secretbox_KEYBYTES);
expect(salt).toHaveLength(SALT_BYTES);
expect(opslimit).toBe(sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE);
expect(memlimit).toBe(sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE);
expect(alg).toBe(sodium.crypto_pwhash_ALG_ARGON2ID13);
} finally {
spy.mockRestore();
}
});
});
describe("wrong password", () => {
test("is rejected, and rejects cleanly", async () => {
// rejects.toThrow asserts a rejected promise, not a synchronous throw
// and not an unhandled rejection: the caller can catch this.
await expect(
decryptWithPassword(vault, WRONG_PASSWORD),
).rejects.toThrow();
});
test("returns no plaintext, not even partially", async () => {
const result = await decryptWithPassword(vault, WRONG_PASSWORD).catch(
(err) => err,
);
expect(result).toBeInstanceOf(Error);
expect(String(result)).not.toContain("test");
});
test("the empty password is rejected on a password-protected vault", async () => {
await expect(decryptWithPassword(vault, "")).rejects.toThrow();
});
});
describe("tampering", () => {
test("a flipped ciphertext bit is rejected by the auth tag", async () => {
const tampered = withField(
vault,
"ciphertext",
flipByte(vault.ciphertext, 0),
);
await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
});
test("a flipped bit in the authentication tag itself is rejected", async () => {
const tagStart = b64decode(vault.ciphertext).length - 1;
const tampered = withField(
vault,
"ciphertext",
flipByte(vault.ciphertext, tagStart),
);
await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
});
test("a flipped nonce bit is rejected", async () => {
const tampered = withField(vault, "nonce", flipByte(vault.nonce, 0));
await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
});
test("a flipped salt bit is rejected", async () => {
const tampered = withField(vault, "salt", flipByte(vault.salt, 0));
await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
});
test("a truncated ciphertext is rejected", async () => {
const bytes = b64decode(vault.ciphertext);
const tampered = withField(
vault,
"ciphertext",
sodium.to_base64(bytes.slice(0, bytes.length - 4)),
);
await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
});
test("a ciphertext shorter than the auth tag is rejected", async () => {
const tampered = withField(
vault,
"ciphertext",
sodium.to_base64(b64decode(vault.ciphertext).slice(0, 4)),
);
await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
});
test("a truncated nonce is rejected", async () => {
const tampered = withField(
vault,
"nonce",
sodium.to_base64(b64decode(vault.nonce).slice(0, NONCE_BYTES - 1)),
);
await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
});
test("a ciphertext from another vault is rejected", async () => {
const other = await encryptWithPassword("a different secret", PASSWORD);
const spliced = withField(vault, "ciphertext", other.ciphertext);
await expect(decryptWithPassword(spliced, PASSWORD)).rejects.toThrow();
});
test("a missing field is rejected rather than decrypted", async () => {
for (const field of ["salt", "nonce", "ciphertext"]) {
const broken = { ...vault };
delete broken[field];
await expect(
decryptWithPassword(broken, PASSWORD),
).rejects.toThrow();
}
});
});

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

@@ -0,0 +1,542 @@
// Tests for src/shared/wallet.js: the DEBUG build flag as it gates mnemonic
// generation (first two describes), and HD key derivation against published
// known-answer vectors (rest of the file).
//
// 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,
);
});
});
// ---------------------------------------------------------------------------
// Key derivation.
//
// Every address below is a published constant, not something this codebase
// produced. Asserting against what the implementation happens to return today
// would pass just as happily with the wrong coin type, the wrong path depth or
// a non-empty seed passphrase, all of which silently send funds to addresses
// no other wallet can recover.
//
// Vector sources:
//
// VECTOR_PHRASE / VECTOR_ADDRESSES / VECTOR_PRIVATE_KEYS — the standard
// development recovery phrase and the first three accounts it yields at
// m/44'/60'/0'/0/n with an empty seed passphrase, as published in the
// Hardhat and Ganache documentation. Publicly known; never fund it.
//
// ZERO_ENTROPY_PHRASE / ZERO_ENTROPY_ADDRESS — the BIP-39 all-zero-entropy
// phrase (Trezor's official BIP-39 vector set, first entry) and its
// m/44'/60'/0'/0/0 Ethereum address with an empty seed passphrase. A second,
// independently published phrase so the pin is not one vector deep.
//
// BIP32_VECTOR_1_XPRV — the master key of BIP-32 test vector 1
// (seed 000102030405060708090a0b0c0d0e0f).
//
// The two Hardhat facts cross-check each other: VECTOR_PRIVATE_KEYS[n] is the
// published key for VECTOR_ADDRESSES[n], so addressFromPrivateKey and the HD
// path must meet at the same address from two different directions.
const { HDNodeWallet, Mnemonic, verifyMessage } = require("ethers");
const wallet = require("../src/shared/wallet");
const { BIP44_ETH_PATH } = require("../src/shared/constants");
const VECTOR_PHRASE =
"test test test test test test test test test test test junk";
const VECTOR_ADDRESSES = [
"0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
"0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
"0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",
];
const VECTOR_PRIVATE_KEYS = [
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d",
"0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a",
];
const ZERO_ENTROPY_PHRASE =
"abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon about";
const ZERO_ENTROPY_ADDRESS = "0x9858EfFD232B4033E47d90003D41EC34EcaEda94";
const BIP32_VECTOR_1_XPRV =
"xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqji" +
"ChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi";
// The master (depth-0) extended private key for a phrase, which is what the
// import-an-xprv flow is handed. Built with ethers rather than with the module
// under test, so hdWalletFromXprv is not being checked against itself.
function masterXprv(phrase, passphrase = "") {
return HDNodeWallet.fromSeed(
Mnemonic.fromPhrase(phrase, passphrase).computeSeed(),
).extendedKey;
}
// The account-level (depth-3) extended private key m/44'/60'/0' for a phrase.
// A normal thing for a user to hold, and not something the import flow can
// derive the BIP-44 account path from.
function accountXprv(phrase) {
return HDNodeWallet.fromSeed(
Mnemonic.fromPhrase(phrase, "").computeSeed(),
).derivePath("m/44'/60'/0'").extendedKey;
}
// Every single-character substitution of `key`, using base58 characters that
// are not the original. Base58 has no visually ambiguous characters, so each
// of these is a plausible typo rather than a contrived string.
const TYPO_CHARS = ["a", "b", "2", "Z"];
function singleCharacterTypos(key) {
const out = [];
for (let i = 0; i < key.length; i++) {
for (const c of TYPO_CHARS) {
if (c === key[i]) continue;
out.push(key.slice(0, i) + c + key.slice(i + 1));
}
}
return out;
}
describe("hdWalletFromMnemonic", () => {
test("first address matches the published vector for m/44'/60'/0'/0/0", () => {
expect(wallet.hdWalletFromMnemonic(VECTOR_PHRASE).firstAddress).toBe(
VECTOR_ADDRESSES[0],
);
});
test("second published phrase derives its published address", () => {
expect(
wallet.hdWalletFromMnemonic(ZERO_ENTROPY_PHRASE).firstAddress,
).toBe(ZERO_ENTROPY_ADDRESS);
});
test("returns the account-level xpub, which is watch-only", () => {
const { xpub } = wallet.hdWalletFromMnemonic(VECTOR_PHRASE);
expect(xpub.startsWith("xpub")).toBe(true);
// A neutered ethers node exposes no private key at all, so accept
// either absent or null rather than pinning which.
expect(
HDNodeWallet.fromExtendedKey(xpub).privateKey ?? null,
).toBeNull();
expect(wallet.isValidXprv(xpub)).toBe(false);
});
test("the account path is the documented BIP-44 Ethereum path", () => {
expect(BIP44_ETH_PATH).toBe("m/44'/60'/0'/0");
});
test("rejects an invalid recovery phrase rather than deriving from it", () => {
expect(() => wallet.hdWalletFromMnemonic("not a phrase")).toThrow();
});
});
describe("deriveAddressFromXpub", () => {
const { xpub } = wallet.hdWalletFromMnemonic(VECTOR_PHRASE);
test.each([0, 1, 2])(
"child %i matches the published vector address",
(index) => {
expect(wallet.deriveAddressFromXpub(xpub, index)).toBe(
VECTOR_ADDRESSES[index],
);
},
);
test("agrees with hdWalletFromMnemonic at index 0", () => {
expect(wallet.deriveAddressFromXpub(xpub, 0)).toBe(
wallet.hdWalletFromMnemonic(VECTOR_PHRASE).firstAddress,
);
});
test("rejects garbage instead of returning an address", () => {
expect(() =>
wallet.deriveAddressFromXpub("xpub-nonsense", 0),
).toThrow();
});
});
describe("hdWalletFromMnemonic seed passphrase handling", () => {
// The vectors above are only reproducible with an empty BIP-39 seed
// passphrase. This pins that the empty string reaching
// HDNodeWallet.fromPhrase is load-bearing: with any passphrase applied the
// published address is unreachable, and a wallet derived that way could
// not be restored anywhere else from the phrase alone.
test("a non-empty seed passphrase would yield a different address", () => {
const withPassphrase = HDNodeWallet.fromPhrase(
VECTOR_PHRASE,
"TREZOR",
BIP44_ETH_PATH,
).deriveChild(0).address;
expect(withPassphrase).not.toBe(VECTOR_ADDRESSES[0]);
});
});
describe("hdWalletFromXprv", () => {
// hdWalletFromMnemonic derives the absolute path "m/44'/60'/0'/0" while
// hdWalletFromXprv derives the relative path "44'/60'/0'/0". For a
// depth-0 master key the two are the same derivation; these tests pin that
// equivalence to a published address rather than assuming it.
test("master xprv for the vector phrase yields the vector address", () => {
expect(
wallet.hdWalletFromXprv(masterXprv(VECTOR_PHRASE)).firstAddress,
).toBe(VECTOR_ADDRESSES[0]);
});
test("agrees with hdWalletFromMnemonic on xpub and address", () => {
const fromPhrase = wallet.hdWalletFromMnemonic(VECTOR_PHRASE);
const fromXprv = wallet.hdWalletFromXprv(masterXprv(VECTOR_PHRASE));
expect(fromXprv).toEqual(fromPhrase);
});
test("derived xpub generates the same child addresses", () => {
const { xpub } = wallet.hdWalletFromXprv(masterXprv(VECTOR_PHRASE));
expect(
[0, 1, 2].map((i) => wallet.deriveAddressFromXpub(xpub, i)),
).toEqual(VECTOR_ADDRESSES);
});
test("accepts the BIP-32 test vector 1 master key", () => {
const { xpub, firstAddress } =
wallet.hdWalletFromXprv(BIP32_VECTOR_1_XPRV);
expect(xpub.startsWith("xpub")).toBe(true);
expect(firstAddress).toMatch(/^0x[0-9a-fA-F]{40}$/);
});
test("rejects a watch-only xpub", () => {
const { xpub } = wallet.hdWalletFromMnemonic(VECTOR_PHRASE);
expect(() => wallet.hdWalletFromXprv(xpub)).toThrow();
});
test("rejects garbage", () => {
expect(() => wallet.hdWalletFromXprv("nonsense")).toThrow();
});
});
describe("isValidXprv", () => {
test.each([
["BIP-32 test vector 1 master key", BIP32_VECTOR_1_XPRV, true],
["the empty string", "", false],
["garbage", "not-a-key", false],
["a bare private key", VECTOR_PRIVATE_KEYS[0], false],
["a truncated xprv", BIP32_VECTOR_1_XPRV.slice(0, -6), false],
["an xprv with an extra character", BIP32_VECTOR_1_XPRV + "a", false],
])("%s -> %s", (_name, key, expected) => {
expect(wallet.isValidXprv(key)).toBe(expected);
});
test("a watch-only xpub is not an xprv", () => {
const { xpub } = wallet.hdWalletFromMnemonic(VECTOR_PHRASE);
expect(wallet.isValidXprv(xpub)).toBe(false);
});
test("rejects an extended key with a one-character typo", () => {
const index = BIP32_VECTOR_1_XPRV.length - 8;
const typo =
BIP32_VECTOR_1_XPRV.slice(0, index) +
(BIP32_VECTOR_1_XPRV[index] === "a" ? "b" : "a") +
BIP32_VECTOR_1_XPRV.slice(index + 1);
expect(wallet.isValidXprv(typo)).toBe(false);
});
// The base58 checksum exists to make a mistyped key impossible to use, and
// ethers does not enforce it: HDNodeWallet.fromExtendedKey skips checksum
// verification whenever the decoded payload is the usual 82 bytes, which
// is precisely the case it is there to catch. A typo anywhere in the key
// must be refused, not silently turned into someone else's wallet.
test("no single-character typo anywhere in the key is accepted", () => {
const accepted = singleCharacterTypos(BIP32_VECTOR_1_XPRV).filter(
(typo) => wallet.isValidXprv(typo),
);
expect(accepted).toEqual([]);
});
test("a typo never yields a wallet, let alone a different one", () => {
const correct = wallet.hdWalletFromXprv(BIP32_VECTOR_1_XPRV);
const derived = [];
for (const typo of singleCharacterTypos(BIP32_VECTOR_1_XPRV)) {
try {
derived.push(wallet.hdWalletFromXprv(typo).firstAddress);
} catch {
// Rejected, which is the required behaviour.
}
}
expect(derived).toEqual([]);
expect(correct.firstAddress).toBe(
"0x022b971dFF0C43305e691DEd7a14367AF19D6407",
);
});
});
describe("extended key depth", () => {
// hdWalletFromXprv derives the BIP-44 Ethereum account path from the key
// it is given. That is only the path it names when the key is the master
// key. Under an account-level key the same derivation lands at
// m/44'/60'/0'/44'/60'/0'/0, whose addresses correspond to nothing the
// user holds, so a non-master key is refused rather than derived from.
test("a master key is a master key", () => {
expect(wallet.isMasterExtendedKey(masterXprv(VECTOR_PHRASE))).toBe(
true,
);
expect(wallet.isMasterExtendedKey(BIP32_VECTOR_1_XPRV)).toBe(true);
});
test("an account-level key is not a master key", () => {
expect(wallet.isMasterExtendedKey(accountXprv(VECTOR_PHRASE))).toBe(
false,
);
});
test("a derived xpub is not a master key", () => {
expect(
wallet.isMasterExtendedKey(
wallet.hdWalletFromMnemonic(VECTOR_PHRASE).xpub,
),
).toBe(false);
});
test("a mistyped key is not a master key either", () => {
expect(wallet.isMasterExtendedKey(BIP32_VECTOR_1_XPRV + "a")).toBe(
false,
);
});
test("hdWalletFromXprv rejects an account-level key", () => {
expect(() =>
wallet.hdWalletFromXprv(accountXprv(VECTOR_PHRASE)),
).toThrow(/master/i);
});
test("getSignerForAddress rejects an account-level key", () => {
expect(() =>
wallet.getSignerForAddress(
{ type: "xprv" },
0,
accountXprv(VECTOR_PHRASE),
),
).toThrow(/master/i);
});
test("the account-level key is well-formed, so only depth rejects it", () => {
expect(wallet.isValidXprv(accountXprv(VECTOR_PHRASE))).toBe(true);
});
test("a master key still imports and derives the published addresses", () => {
const { xpub, firstAddress } = wallet.hdWalletFromXprv(
masterXprv(VECTOR_PHRASE),
);
expect(firstAddress).toBe(VECTOR_ADDRESSES[0]);
expect(
[0, 1, 2].map((i) => wallet.deriveAddressFromXpub(xpub, i)),
).toEqual(VECTOR_ADDRESSES);
});
});
describe("deriveAddressFromXpub checksum enforcement", () => {
// The xpub path shares the hole: fromExtendedKey accepts a mistyped xpub
// just as readily, and deriveAddressFromXpub would hand back addresses
// from a different tree.
const { xpub } = wallet.hdWalletFromMnemonic(VECTOR_PHRASE);
test("the correct xpub still derives the published addresses", () => {
expect(wallet.deriveAddressFromXpub(xpub, 0)).toBe(VECTOR_ADDRESSES[0]);
});
test("no single-character typo anywhere in an xpub is accepted", () => {
const derived = [];
for (const typo of singleCharacterTypos(xpub)) {
try {
derived.push(wallet.deriveAddressFromXpub(typo, 0));
} catch {
// Rejected, which is the required behaviour.
}
}
expect(derived).toEqual([]);
});
});
describe("isValidMnemonic", () => {
test.each([
["the vector phrase", VECTOR_PHRASE, true],
["the BIP-39 zero-entropy phrase", ZERO_ENTROPY_PHRASE, true],
[
"a 12-word phrase with a bad checksum",
"abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon abandon",
false,
],
["an 11-word phrase", "abandon ".repeat(10) + "about", false],
["a word outside the wordlist", VECTOR_PHRASE + " zzzzzz", false],
["the empty string", "", false],
["garbage", "correct horse battery staple", false],
])("%s -> %s", (_name, phrase, expected) => {
expect(wallet.isValidMnemonic(phrase)).toBe(expected);
});
});
describe("addressFromPrivateKey", () => {
test.each([0, 1, 2])(
"published key %i yields its published address",
(index) => {
expect(
wallet.addressFromPrivateKey(VECTOR_PRIVATE_KEYS[index]),
).toBe(VECTOR_ADDRESSES[index]);
},
);
test("rejects a key of the wrong length", () => {
expect(() => wallet.addressFromPrivateKey("0xdeadbeef")).toThrow();
});
test("rejects the empty string", () => {
expect(() => wallet.addressFromPrivateKey("")).toThrow();
});
});
describe("getSignerForAddress", () => {
test.each([0, 1, 2])("hd wallet, address index %i", (index) => {
const signer = wallet.getSignerForAddress(
{ type: "hd" },
index,
VECTOR_PHRASE,
);
expect(signer.address).toBe(VECTOR_ADDRESSES[index]);
expect(signer.privateKey).toBe(VECTOR_PRIVATE_KEYS[index]);
});
test.each([0, 1, 2])("xprv wallet, address index %i", (index) => {
const signer = wallet.getSignerForAddress(
{ type: "xprv" },
index,
masterXprv(VECTOR_PHRASE),
);
expect(signer.address).toBe(VECTOR_ADDRESSES[index]);
expect(signer.privateKey).toBe(VECTOR_PRIVATE_KEYS[index]);
});
test("single private key ignores the address index", () => {
for (const index of [0, 1, 2]) {
const signer = wallet.getSignerForAddress(
{ type: "privkey" },
index,
VECTOR_PRIVATE_KEYS[1],
);
expect(signer.address).toBe(VECTOR_ADDRESSES[1]);
}
});
test("the returned signer signs recoverably as the expected address", async () => {
const signer = wallet.getSignerForAddress(
{ type: "hd" },
1,
VECTOR_PHRASE,
);
const message = "AutistMask derivation test";
const signature = await signer.signMessage(message);
expect(verifyMessage(message, signature)).toBe(VECTOR_ADDRESSES[1]);
});
});

129
tests/walletDelete.test.js Normal file
View File

@@ -0,0 +1,129 @@
const {
removeWalletFromState,
broadcastActiveChanged,
} = require("../src/shared/walletDelete");
// Fixed addresses — never used for anything but these tests.
const A0 = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const A1 = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const B0 = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599";
const C0 = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
function wallet(name, addresses) {
return {
name,
addresses: addresses.map((address) => ({ address })),
};
}
// A three-wallet state; wallet A is an HD wallet with two addresses.
function makeState(overrides = {}) {
return {
hasWallet: true,
wallets: [wallet("A", [A0, A1]), wallet("B", [B0]), wallet("C", [C0])],
selectedWallet: 0,
selectedAddress: 0,
activeAddress: A0,
allowedSites: {
[A0]: ["a.example"],
[A1]: ["b.example"],
[B0]: ["c.example"],
},
deniedSites: { [A1]: ["d.example"], [C0]: ["e.example"] },
...overrides,
};
}
describe("removeWalletFromState", () => {
test("deleting the last wallet clears hasWallet", () => {
const state = makeState({
wallets: [wallet("A", [A0])],
allowedSites: { [A0]: ["a.example"] },
deniedSites: {},
});
const { activeAddressChanged } = removeWalletFromState(state, 0);
expect(state.hasWallet).toBe(false);
expect(state.wallets).toEqual([]);
expect(state.selectedWallet).toBeNull();
expect(state.selectedAddress).toBeNull();
expect(state.activeAddress).toBeNull();
expect(activeAddressChanged).toBe(true);
});
test("deleting a non-selected wallet leaves the selection intact", () => {
const state = makeState({
selectedWallet: 2,
selectedAddress: 0,
activeAddress: C0,
});
const { activeAddressChanged } = removeWalletFromState(state, 1);
// Wallet C moved from index 2 to index 1 by the splice.
expect(state.wallets.map((w) => w.name)).toEqual(["A", "C"]);
expect(state.selectedWallet).toBe(1);
expect(state.selectedAddress).toBe(0);
expect(state.activeAddress).toBe(C0);
expect(activeAddressChanged).toBe(false);
expect(state.hasWallet).toBe(true);
});
test("deleting a wallet after the selection does not shift it", () => {
const state = makeState({
selectedWallet: 1,
selectedAddress: 0,
activeAddress: B0,
});
const { activeAddressChanged } = removeWalletFromState(state, 2);
expect(state.selectedWallet).toBe(1);
expect(state.activeAddress).toBe(B0);
expect(activeAddressChanged).toBe(false);
});
test("deleting the active wallet falls back to the first remaining address", () => {
const state = makeState({
selectedWallet: 0,
selectedAddress: 1,
activeAddress: A1,
});
const { activeAddressChanged } = removeWalletFromState(state, 0);
expect(state.wallets.map((w) => w.name)).toEqual(["B", "C"]);
expect(state.selectedWallet).toBe(0);
expect(state.selectedAddress).toBe(0);
expect(state.activeAddress).toBe(B0);
expect(activeAddressChanged).toBe(true);
expect(state.hasWallet).toBe(true);
});
test("site permissions are dropped for every address of the wallet", () => {
const state = makeState();
removeWalletFromState(state, 0);
expect(state.allowedSites).toEqual({ [B0]: ["c.example"] });
expect(state.deniedSites).toEqual({ [C0]: ["e.example"] });
});
});
describe("broadcastActiveChanged", () => {
afterEach(() => {
delete global.chrome;
});
test("sends AUTISTMASK_ACTIVE_CHANGED to the background", () => {
const sendMessage = jest.fn();
global.chrome = { runtime: { sendMessage } };
broadcastActiveChanged();
expect(sendMessage).toHaveBeenCalledWith({
type: "AUTISTMASK_ACTIVE_CHANGED",
});
});
});

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"