Compare commits

..

10 Commits

Author SHA1 Message Date
ff3387d8cf feat: vendor and censor the phishing blocklist at build time (closes #219)
All checks were successful
check / check (push) Successful in 27s
e2e / e2e-chrome (push) Successful in 48s
e2e / e2e-firefox (push) Successful in 21s
2026-08-17 10:05:56 +02:00
8fcdd8a053 fix: settle a site approval on the port that carries its teardown (closes #275)
All checks were successful
check / check (push) Successful in 38s
e2e / e2e-chrome (push) Successful in 48s
e2e / e2e-firefox (push) Successful in 23s
2026-08-17 09:34:07 +02:00
7690fe6429 fix: answer the page when a background handler throws (closes #280)
All checks were successful
check / check (push) Successful in 36s
e2e / e2e-chrome (push) Successful in 48s
e2e / e2e-firefox (push) Successful in 22s
2026-08-17 09:16:21 +02:00
a60c4a616a test: drive the Settings screen in a browser and guard every popup element id (closes #229)
Some checks failed
check / check (push) Has been cancelled
e2e / e2e-chrome (push) Has been cancelled
e2e / e2e-firefox (push) Has been cancelled
2026-08-17 09:14:59 +02:00
47bf38644d build: add ESLint to script/lint and containerize linting (closes #152)
All checks were successful
check / check (push) Successful in 39s
e2e / e2e-chrome (push) Successful in 48s
e2e / e2e-firefox (push) Successful in 24s
2026-08-17 09:10:03 +02:00
4b7a678a9b refactor: one shared extension-API module, and drive the dApp flows on Firefox (closes #153)
All checks were successful
check / check (push) Successful in 27s
e2e / e2e-chrome (push) Successful in 45s
e2e / e2e-firefox (push) Successful in 23s
2026-08-17 09:06:13 +02:00
ab1c1846a7 fix: one wording for an empty password field on every screen (closes #265)
All checks were successful
check / check (push) Successful in 29s
e2e / e2e-chrome (push) Successful in 48s
e2e / e2e-firefox (push) Successful in 18s
2026-08-17 08:59:59 +02:00
743b1962a5 build: run the browser e2e suites in CI (closes #259)
All checks were successful
check / check (push) Successful in 28s
e2e / e2e-chrome (push) Successful in 47s
e2e / e2e-firefox (push) Successful in 20s
2026-08-17 08:52:26 +02:00
c06765ef8f fix: one transaction approval at a time, and honest copy for a nonce collision (closes #271)
All checks were successful
check / check (push) Successful in 28s
2026-08-17 08:38:26 +02:00
e07efb710a fix: an address holding only unpriced tokens is no longer totalled at $0.00 (closes #261)
Some checks failed
check / check (push) Has been cancelled
2026-08-17 08:38:10 +02:00
67 changed files with 6550 additions and 233110 deletions

49
.gitea/workflows/e2e.yml Normal file
View File

@@ -0,0 +1,49 @@
name: e2e
on: [push]
# The browser end-to-end suites, one job per browser, deliberately kept out
# of the check workflow: REPO_POLICIES.md caps make test at 20 seconds and
# script/cibuild is a plain `docker build .` whose Dockerfile runs
# make check, so folding a browser suite into either would blow that cap
# and slow the local fast path. Before this workflow every browser-level
# guarantee in this repo held only when a human remembered to run it.
#
# One job per browser rather than two steps in one job, so a Chrome failure
# does not hide the Firefox result.
#
# Each job is one script and nothing else. Both scripts need docker and
# nothing else — they deliver the repo to the daemon as a build context and
# build the extension inside the pinned image — which is what makes them
# runnable here at all: the runner executes the job in a container against
# the host's docker socket, so a `-v "$PWD:/work"` source path is resolved
# by the host daemon and mounts an empty directory, and the runner image's
# node is too old to install this repo's dependencies.
#
# These jobs REPORT, they do not gate. Whether a check blocks a merge is
# Gitea branch protection, which this repo does not configure, so a failure
# here is a red mark a reviewer has to account for rather than a hard
# block. Making e2e-chrome a required check is blocked on the measured
# flake in the dApp signing wait -- two of six runs of unmutated code on a
# loaded machine -- tracked as
# https://git.eeqj.de/sneak/AutistMask/issues/287. A gate that fails at
# random teaches people to merge past red.
#
# Nothing here may pass vacuously. There is no continue-on-error and no
# `|| true`. Both scripts exit non-zero when docker is missing, when the
# image build fails, and when the browser fails to start; the Chrome
# harness aborts the suite outright if its network interception is not in
# effect.
jobs:
e2e-chrome:
runs-on: ubuntu-latest
steps:
# actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: script/test-e2e
e2e-firefox:
runs-on: ubuntu-latest
steps:
# actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: script/test-e2e-firefox

View File

@@ -1,8 +1,21 @@
# node:22-slim (22.x LTS), 2026-02-24 # node:22-slim (22.x LTS), 2026-02-24
FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36 FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36 AS base
WORKDIR /app WORKDIR /app
# Marks "already inside the lint container" for script/lint, which otherwise
# shells out to docker to build the lint stage below. Nothing outside this
# image sets it.
ENV AUTISTMASK_LINT_NATIVE=1
# script/test's default 30s bound is the host figure, against a suite that
# runs in about 8s there. In here the same suite starts on a cold jest cache
# and shares the runner with the rest of the build, so 30s is marginal rather
# than a bound — it killed a healthy suite at 30.6s on a cold CI cache. 180s
# still catches a hang in three minutes and cannot be tripped by a suite that
# is merely running on contended hardware.
ENV AUTISTMASK_TEST_TIMEOUT=180
# script/bootstrap installs all prerequisites (make via apt here; node # script/bootstrap installs all prerequisites (make via apt here; node
# is already in the base image, yarn comes via corepack) and runs # is already in the base image, yarn comes via corepack) and runs
# yarn install --frozen-lockfile. Dependency manifests are copied first # yarn install --frozen-lockfile. Dependency manifests are copied first
@@ -13,5 +26,17 @@ RUN script/bootstrap
COPY . . COPY . .
# Lint stage — fail fast on static analysis and formatting, before the tests
# and the build. This is also the stage script/lint builds from a host, which
# is how linting stays on the pinned ESLint rather than the host's.
FROM base AS lint
RUN make lint
# Full check and build. The COPY --from is a no-op file copy whose only job is
# to make BuildKit finish the lint stage before this one starts; without it the
# stages run in parallel and a lint failure would not fail the build early.
FROM base AS check
COPY --from=lint /app/package.json /dev/null
RUN make check RUN make check
RUN make build RUN make build

View File

@@ -682,7 +682,14 @@ under their own licenses. They are NOT covered by the GPL-3.0 license above.
--------------------------------------------------------------------------- ---------------------------------------------------------------------------
File: src/shared/phishingBlocklist.json File: src/shared/phishingBlocklist.json
Source: https://github.com/AugurProject/eth-phishing-detect (config.json) Source: the eth-phishing-detect community blocklist (src/config.json).
The file here is derived from it, not a copy of it: only the
blacklist is carried over, and each entry is stored as a truncated
digest rather than a domain name. script/vendor-blocklist records
the exact upstream URL, the commit it is pinned to and the hash of
the bytes that commit serves, and is what regenerates this file.
The URL previously cited here, under a different organisation,
returns 404: that repository is gone.
Copyright: Copyright (c) 2018 kumavis Copyright: Copyright (c) 2018 kumavis
License: Don't Be a Dick Public License (DBAD), Version 1.2 License: Don't Be a Dick Public License (DBAD), Version 1.2
--------------------------------------------------------------------------- ---------------------------------------------------------------------------

View File

@@ -1,4 +1,4 @@
.PHONY: bootstrap setup install test test-e2e test-e2e-firefox lint fmt fmt-check check docker hooks build build-debug verify-build clean dev .PHONY: bootstrap setup install test test-e2e test-e2e-firefox lint fmt fmt-check check check-censored docker hooks build build-debug verify-build vendor-blocklist 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
@@ -35,6 +35,12 @@ fmt-check:
check: check:
@script/check @script/check
# Assert that the competitor name appears nowhere but its documented
# exceptions. Part of check, and re-run against dist/ at the end of a build;
# separate target for re-running it alone.
check-censored:
@script/check-censored
docker: docker:
@script/docker @script/docker
@@ -45,6 +51,7 @@ build:
@echo "Building extension..." @echo "Building extension..."
@yarn run build 2>&1 @yarn run build 2>&1
@script/verify-build @script/verify-build
@script/check-censored --require-dist
# Development-only build: enables the red DEBUG / INSECURE banner and makes # Development-only build: enables the red DEBUG / INSECURE banner and makes
# the hardcoded test recovery phrase the output of wallet creation. Never # the hardcoded test recovery phrase the output of wallet creation. Never
@@ -53,12 +60,19 @@ build-debug:
@echo "Building extension (DEBUG)..." @echo "Building extension (DEBUG)..."
@AUTISTMASK_DEBUG=1 yarn run build 2>&1 @AUTISTMASK_DEBUG=1 yarn run build 2>&1
@AUTISTMASK_DEBUG=1 script/verify-build @AUTISTMASK_DEBUG=1 script/verify-build
@script/check-censored --require-dist
# Assert the compiled DEBUG state of the bundles already in dist/. Runs at # 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. # the end of build and build-debug; separate target for re-running it alone.
verify-build: verify-build:
@script/verify-build @script/verify-build
# Refresh src/shared/phishingBlocklist.json from its hash-pinned upstream.
# Run deliberately, land the diff: the extension does no runtime fetching, so
# the shipped list is as fresh as the last vendoring run that was released.
vendor-blocklist:
@script/vendor-blocklist
clean: clean:
@rm -rf dist/ @rm -rf dist/

437
README.md
View File

@@ -18,9 +18,10 @@ don't implement any crypto, and don't send user-specific data anywhere but a
extension contacts three user-configurable services: the configured RPC node for extension contacts three user-configurable services: the configured RPC node for
blockchain interactions, a public CoinDesk API (no API key) for realtime price blockchain interactions, a public CoinDesk API (no API key) for realtime price
information, and a Blockscout block-explorer API for transaction history and information, and a Blockscout block-explorer API for transaction history and
token balances. It also fetches a community-maintained phishing domain blocklist token balances. It also performs best-effort Etherscan address label lookups
periodically and performs best-effort Etherscan address label lookups during during transaction confirmation. A community-maintained phishing domain
transaction confirmation. blocklist is built into the extension at build time and checked locally; nothing
is fetched for it at runtime.
In the extension is a hardcoded list of the top ERC20 contract addresses. You In the extension is a hardcoded list of the top ERC20 contract addresses. You
can add any ERC20 contract by contract address if you wish, but the hardcoded can add any ERC20 contract by contract address if you wish, but the hardcoded
@@ -83,15 +84,36 @@ provide:
git pre-commit hook git pre-commit hook
- `script/projectname` — print the project name (used for the Docker image tag) - `script/projectname` — print the project name (used for the Docker image tag)
- `script/test` — run the test suite (jest) - `script/test` — run the test suite (jest)
- `script/test-e2e` — run the Chrome browser end-to-end suite (docker required; - `script/test-e2e` — run the Chrome browser end-to-end suite (docker is the
see [End-to-End Tests](#end-to-end-tests)) only prerequisite: it builds a pinned image that carries the repo and a fresh
- `script/test-e2e-firefox` — run the Firefox browser end-to-end suite (docker extension build, see [End-to-End Tests](#end-to-end-tests))
required; builds its own pinned image, see - `script/test-e2e-firefox` — run the Firefox browser end-to-end suite (same,
against an image with a pinned Firefox and geckodriver, see
[End-to-End Tests](#end-to-end-tests)) [End-to-End Tests](#end-to-end-tests))
- `script/lint` — run the linter - `script/lint` — run ESLint (`eslint.config.js`) and then `prettier --check`,
failing on either. It never writes: `--fix` is not in this path, so
`make check` stays non-mutating. Linting runs in the container — the script
builds the Dockerfile's `lint` stage — because an ESLint result that depends
on whichever ESLint the host happens to have is not a result. Docker is
therefore required to lint; inside that image `AUTISTMASK_LINT_NATIVE=1` makes
the same script lint in place instead of recursing.
- `script/fmt` — format all files (writes) - `script/fmt` — format all files (writes)
- `script/fmt-check` — check formatting (read-only) - `script/fmt-check` — check formatting (read-only)
- `script/check` — run test, test-verify-build, lint, and fmt-check - `script/check` — run test, test-verify-build, check-censored, lint, and
fmt-check
- `script/check-censored` — assert the competitor name RULES.md bars appears
nowhere in the working tree or under `dist/` outside its documented
exceptions: the pinned source reference in `script/vendor-blocklist`, the two
provider-shim identifiers in `src/content/inpage.js`, and one ERC-20's
on-chain name in `src/shared/tokenList.js`. Each is scoped to that path and
fails anywhere else. Part of `make check`, which inspects `dist/` when there
is one and says loudly when there is not; `make build` re-runs it with
`--require-dist`, so a build artifact is always covered
- `script/vendor-blocklist` — refresh `src/shared/phishingBlocklist.json` from
its upstream, pinned to a commit and to the sha256 of the bytes that commit
serves. Run deliberately, never as part of a build: the output is committed
and there is no runtime fetch, so the shipped list is as fresh as the last
vendoring run that was released
- `script/verify-build` — assert the compiled `DEBUG` state of the bundles in - `script/verify-build` — assert the compiled `DEBUG` state of the bundles in
`dist/`: every bundle containing `src/shared/constants.js` must have `DEBUG` `dist/`: every bundle containing `src/shared/constants.js` must have `DEBUG`
off, or on when `AUTISTMASK_DEBUG=1`. Run automatically at the end of off, or on when `AUTISTMASK_DEBUG=1`. Run automatically at the end of
@@ -136,11 +158,12 @@ are outside `make check`.
`make test-e2e` builds `dist/chrome/` and drives the **real popup in a real `make test-e2e` builds `dist/chrome/` and drives the **real popup in a real
Chrome**, loaded as an unpacked MV3 extension inside a pinned Chrome**, loaded as an unpacked MV3 extension inside a pinned
`mcr.microsoft.com/playwright` container (pinned by digest in `script/test-e2e`; `mcr.microsoft.com/playwright` container (pinned by digest in
docker is required and the suite fails loudly rather than skipping if it is `tests/e2e/Dockerfile`, which is also where the extension is built; docker is
unavailable). The suite lives in `tests/e2e/` and is driven by required and the suite fails loudly rather than skipping if it is unavailable).
`playwright-core`, whose version must stay matched to the container's Playwright The suite lives in `tests/e2e/` and is driven by `playwright-core`, whose
version — the browsers ship inside the image. version must stay matched to the container's Playwright version — the browsers
ship inside the image.
It covers popup load, WebAssembly compilation under the shipped CSP (see It covers popup load, WebAssembly compilation under the shipped CSP (see
[Content Security Policy](#content-security-policy)), wallet creation through [Content Security Policy](#content-security-policy)), wallet creation through
@@ -157,6 +180,23 @@ fixtures in `tests/e2e/network.js`, so the run is deterministic and fully
offline; unrecognised outbound requests are reported as failures rather than offline; unrecognised outbound requests are reported as failures rather than
silently allowed. silently allowed.
It also covers the **Settings screen**, which holds the densest run of element
id lookups in the codebase and where one wrong id leaves the whole popup blank
rather than only degrading Settings: that the screen renders populated — the
About well and the wallet list are read back, so a `show()` that stopped early
is caught rather than merely a view that failed to appear — that the four Token
Spam Protection controls are real checkboxes defaulted on, and that the theme
and network selectors offer exactly the choices `src/shared/networks.js` and
`index.html` define. What the selectors persist is then driven through the UI to
`dark` and `sepolia` and reasserted after a popup reopen before being restored
the same way; neither value is its selector's first `<option>`, so neither can
be read back from the markup with no JavaScript having run. One spam filter is
likewise toggled off and back on across a reopen each way, which exercises the
change handler, `saveState()`, `loadState()` and the assignment `init()` makes.
Each group of these assertions records a coverage key and a final case demands
the exact set, so a section that silently stopped running reddens the suite
instead of quietly shrinking it.
It also covers the confirmation screen, for both a native ETH send and an ERC-20 It also covers the confirmation screen, for both a native ETH send and an ERC-20
send: Send disabled while the fee estimate is in flight, enabled once it lands, send: Send disabled while the fee estimate is in flight, enabled once it lands,
the fee block quoting the expected cost and the reserve separately, the distinct the fee block quoting the expected cost and the reserve separately, the distinct
@@ -213,16 +253,16 @@ That interception covers the MV3 background service worker as well as the popup
page, which it does not by default — `script/test-e2e` sets page, which it does not by default — `script/test-e2e` sets
`PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1` for it. Because that flag is `PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1` for it. Because that flag is
experimental, the harness does not take it on trust. At launch it waits for the experimental, the harness does not take it on trust. At launch it waits for the
background worker's **own** startup request — the phishing blocklist fetch that background worker to exist, asks it for one throwaway `fetch()` of its own, and
`src/background/index.js` issues on startup, which on the suite's throwaway requires that request to arrive in the route handler within 30 seconds or aborts
profile always happens because no previous fetch timestamp is persisted — to the entire suite (`tests/e2e/harness.js`). The anchor used to be the worker's
arrive in the route handler, and aborts the entire suite if none does within 30 own startup traffic — the phishing blocklist fetch — and there is no longer any:
seconds (`tests/e2e/harness.js`). The check is passive on purpose: a synthetic the blocklist is vendored at build time and the extension contacts nobody when
probe fetched from inside the worker via `worker.evaluate()` was tried first and it starts. An earlier synthetic probe was rejected because evaluating in an
rejected, because evaluating in an extension service worker that early kills the extension service worker immediately after launch killed the worker outright;
worker outright, destroying the thing being measured. Observing traffic the waiting for the worker to be handed over first, and issuing a `fetch()` that is
extension already generates perturbs nothing. Losing the race fails closed — the not awaited, does not. Failing the probe fails closed — the suite refuses to run
suite refuses to run rather than passing quietly. rather than passing quietly.
As defence in depth, Chrome is also started with As defence in depth, Chrome is also started with
`--host-resolver-rules=MAP * ~NOTFOUND`, so a request that ever did slip past `--host-resolver-rules=MAP * ~NOTFOUND`, so a request that ever did slip past
@@ -232,24 +272,37 @@ being intercepted, run with `E2E_TRACE_NETWORK=1` and every routed request is
printed, tagged `[sw]` or `[page]`. printed, tagged `[sw]` or `[page]`.
**Any uncaught page error or `console.error` fails the run.** That is the point: **Any uncaught page error or `console.error` fails the run.** That is the point:
a `ReferenceError` from a used-but-not-imported identifier is invisible to this suite exists because a `ReferenceError` from a used-but-not-imported
`make check` (`script/lint` is only `prettier --check`) but fatal in a browser, identifier shipped twice, fatal in a browser and invisible to a `make check`
and this suite exists because exactly that class of bug shipped twice. that was `prettier --check` only. ESLint's `no-undef` now catches that exact
class before a browser is involved, so this suite is no longer the only thing
standing between it and a release — but a static rule only sees identifiers, and
the runtime errors this suite catches are broader than one rule.
### Firefox (`make test-e2e-firefox`) ### Firefox (`make test-e2e-firefox`)
`make test-e2e-firefox` builds `dist/firefox/` and drives the **real popup in a `make test-e2e-firefox` builds `dist/firefox/` and drives the **real popup in a
real Firefox**, installed as an unpacked MV2 temporary add-on via geckodriver. real Firefox**, installed as an unpacked MV2 temporary add-on via geckodriver.
It covers popup load, wallet creation through the UI, and the Add Token screen. It covers popup load, wallet creation through the UI, the Add Token screen, and
The suite lives in `tests/e2e/firefox/` and has **no npm dependencies at all**: the four dApp round trips — `eth_requestAccounts`, `personal_sign`,
it is a small WebDriver client built on global `fetch` and `child_process` `eth_sendTransaction`, and a closed approval window rejecting with EIP-1193 4001
against geckodriver's HTTP API. — driven through the real content script, background page and approval windows.
Unlike the Chrome suite it builds its own container image rather than pulling a The suite lives in `tests/e2e/firefox/`. Its WebDriver client (`driver.js`) has
published one, because no published image carries both a pinned Firefox and a **no npm dependencies at all**: it is built on global `fetch` and
matching geckodriver. `tests/e2e/firefox/Dockerfile` pins all three external `child_process` against geckodriver's HTTP API. The dApp fixture (`dapp.js`) and
artifacts by digest — the `node` base image, the Firefox 153.0.3 tarball, and the assertions do use `ethers`, and have to — a signature is recovered in the
geckodriver 0.36.0 — and the Firefox version in particular must not float: runner rather than believed from the extension, and the stub node has to answer
`eth_sendRawTransaction` with the hash `ethers` computes for the artifact it
sent, or `provider.broadcastTransaction()` refuses the answer.
Both suites build their own image, each with the repo and a fresh extension
build baked in; what differs is the base. The Chrome image layers those on top
of a published Playwright image, whereas this one is assembled from a `node`
base, because no published image carries both a pinned Firefox and a matching
geckodriver. `tests/e2e/firefox/Dockerfile` pins all three external artifacts by
digest — the `node` base image, the Firefox 153.0.3 tarball, and geckodriver
0.36.0 — and the Firefox version in particular must not float:
`-remote-allow-system-access` is **mandatory** on 153 and was not on 142. `-remote-allow-system-access` is **mandatory** on 153 and was not on 142.
Without that flag, both navigating to `moz-extension://` and running Without that flag, both navigating to `moz-extension://` and running
chrome-context script fail with `unsupported operation`. The flag grants the chrome-context script fail with `unsupported operation`. The flag grants the
@@ -265,20 +318,39 @@ because BiDi's `browsingContext.navigate` refuses `moz-extension://` outright.
**Any uncaught error from a `moz-extension://` source fails the run**, including **Any uncaught error from a `moz-extension://` source fails the run**, including
errors from the background page, which the suite never navigates to: a `throw` errors from the background page, which the suite never navigates to: a `throw`
at the top of `src/background/index.js` kills the background page and fails at the top of `src/background/index.js` kills the background page and fails
step 1. Content-script errors should arrive by the same route, but this suite step 1. Content scripts **are** exercised now — the dApp steps drive a page
does not exercise it and does not claim it — with `--network none` there is no served from loopback, which survives `--network none` — but the _capture_ of a
`http://` page for a content script to be injected into. Errors from add-on content-script error by this route is still unproven: no probe has forced a
install and background startup are folded into step 1 rather than discarded. throw inside one and watched it fail the run, so it remains an expectation
Errors are read from the privileged `nsIConsoleService` in Marionette's chrome rather than a demonstrated fact. Errors from add-on install and background
context and filtered to non-warning entries whose `sourceName` is the extension startup are folded into step 1 rather than discarded.
origin. That mechanism is not a stylistic choice. WebDriver BiDi's
`log.entryAdded` delivers **nothing** for extension pages: on a plain `http://` An **unhandled promise rejection counts as an uncaught error** on both suites,
page it reports uncaught errors with stack traces, and on the `moz-extension://` which matters because a good deal of popup code is now `async` and called
popup it reports zero events, because Firefox's remote agent excludes extension without an `await`. Demonstrated, not assumed: a `throw` placed past the first
browsing contexts from BiDi observation. Any harness built on Playwright-BiDi or `await` of `approval.show()` — which nothing awaits — turns the
Puppeteer-BiDi would therefore see nothing and report success, which is exactly `eth_requestAccounts` step red on Firefox
the vacuous check this repo has already shipped twice. Do not migrate this suite (`uncaught extension errors during this step`, from the console-service drain)
to BiDi. and on Chrome (`pageerror`), with the rest of the run unaffected because the
approval view had already rendered.
One error is tolerated rather than fatal, listed in `ALLOWED_ERRORS` in
`tests/e2e/firefox/run.js` with the issue that will delete it, and printed on
every occurrence so the concession stays visible in the run output. It is
Firefox reporting the site-approval popup's unawaited `sendMessage` settling
after `window.close()` unloaded the context — the same teardown ordering as
[#275](https://git.eeqj.de/sneak/AutistMask/issues/275), and unsuppressable from
the calling code, because `BaseContext.wrapPromise` reports it whether or not a
handler is attached. Errors are read from the privileged `nsIConsoleService` in
Marionette's chrome context and filtered to non-warning entries whose
`sourceName` is the extension origin. That mechanism is not a stylistic choice.
WebDriver BiDi's `log.entryAdded` delivers **nothing** for extension pages: on a
plain `http://` page it reports uncaught errors with stack traces, and on the
`moz-extension://` popup it reports zero events, because Firefox's remote agent
excludes extension browsing contexts from BiDi observation. Any harness built on
Playwright-BiDi or Puppeteer-BiDi would therefore see nothing and report
success, which is exactly the vacuous check this repo has already shipped twice.
Do not migrate this suite to BiDi.
Two limits are worth knowing, both real differences from the Chrome suite: Two limits are worth knowing, both real differences from the Chrome suite:
@@ -302,24 +374,81 @@ Two limits are worth knowing, both real differences from the Chrome suite:
but a step that logs heavily could evict unread errors. What poll-based costs but a step that logs heavily could evict unread errors. What poll-based costs
is location, not coverage: an error cannot be placed within a step the way the is location, not coverage: an error cannot be placed within a step the way the
Chrome suite's `pageerror` events place it. Chrome suite's `pageerror` events place it.
- **Nothing is stubbed, which inverts the coverage of network-dependent code.** - **Almost nothing is stubbed, which inverts the coverage of network-dependent
There is no fixture layer; the container runs with `--network none` instead, code.** The container still runs with `--network none`, so the run is offline
so the run is offline and deterministic and no request can escape. The and no request can escape. The one thing it can reach is the loopback fixture
extension swallows its own fetch failures, so the flows are unaffected — but in `tests/e2e/firefox/dapp.js`, which serves the dApp page and a JSON-RPC node
every network call fails, so only the _failure_ branches of code that depends and which the extension's `rpcUrl` is pointed at for the dApp steps; a
on one are ever executed. A `ReferenceError` in the success path of JSON-RPC method that fixture does not model fails the run rather than
`renderTransactions`, or of price or balance rendering, passes this suite answering `null`. Everything else — Blockscout, the price feed, the phishing
green. The offline run is also weaker than the Chrome suite's interception: it blocklist — has no fixture and simply fails, and the extension swallows its
proves nothing got out, but it cannot report which requests were attempted. own fetch failures, so only the _failure_ branches of that code are ever
Closing that gap needs a fixture layer, deliberately out of scope for this executed. A `ReferenceError` in the success path of `renderTransactions`, or
harness. of price rendering, passes this suite green. The offline run is also weaker
than the Chrome suite's interception for those calls: it proves nothing got
out, but it cannot report which requests were attempted.
Neither `make test-e2e` nor `make test-e2e-firefox` is part of `make check` or Neither `make test-e2e` nor `make test-e2e-firefox` is part of `make check` or
`make test`. `REPO_POLICIES.md` caps `make test` at 20 seconds and a browser `make test`. `REPO_POLICIES.md` caps `make test` at 20 seconds and a browser
suite does not fit; nothing in `tests/e2e/` is named `*.test.js`, so jest cannot suite does not fit; nothing in `tests/e2e/` is named `*.test.js`, so jest cannot
pick it up either. Neither is wired into the Gitea workflow yet — pick it up either. Run them locally before changing anything under
docker-in-docker in CI is a separate question. Run them locally before changing `src/popup/views/`.
anything under `src/popup/views/`.
### In CI
`.gitea/workflows/e2e.yml` runs both suites on every push, as two jobs —
`e2e-chrome` and `e2e-firefox` — separate from the `check` workflow, so the
20-second `make test` cap and the local fast path are untouched. Each job is a
checkout and the matching `script/` entrypoint, nothing else.
Docker is the only thing either job needs from the runner, and that is not an
accident. The runner executes a job inside a container against the **host's**
docker daemon, so a `docker run -v "$PWD:/work"` source path is resolved by the
host and mounts an empty directory, and the runner image's node is too old to
install this repo's dependencies. Both suites therefore ship the repo to the
daemon as a build context and build the extension inside the image, which works
identically on a laptop.
The jobs **report, they do not gate.** A failure is a red mark against the
commit that a reviewer has to account for, not a hard block: whether a check
blocks a merge is Gitea branch protection, which this repo does not configure.
That is not only a statement about configuration. The Chrome suite is
**measurably flaky under load** — two of six runs of unmutated code on a busy
machine lost the approval popup out from under the dApp signing wait, always in
the `#183` section, tracked as
[#287](https://git.eeqj.de/sneak/AutistMask/issues/287). So a red `e2e-chrome`
has to be read before it is believed, and that flake is the blocker to ever
making this a required check. Do not answer it with a retry wrapper: a suite
that reruns until it is green stops being evidence.
Nothing in either job can pass vacuously. There is no `continue-on-error` and no
`|| true`; both scripts exit non-zero when docker is missing, when the image
build fails, and when the browser fails to start; the Chrome harness aborts the
suite outright if its network interception is not in effect.
Measured on this repo's runner: `e2e-chrome` about 1m55s cold, almost all of it
the one-time pull of the pinned ~800MB Playwright layer, and well under a minute
once that layer is cached. `e2e-firefox` about 1m05s cold, and it caches its
Firefox and geckodriver downloads the same way.
### Element id guard (part of `make check`)
`tests/popupElementIds.test.js` asserts statically that every element id the
popup looks up — `$("...")`, `document.getElementById("...")`,
`showError()`/`hideError()`, and the `view-<name>` a literal `showView("...")`
resolves to — exists in `src/popup/index.html`, and that `index.html` defines no
id twice. A wrong id is valid JavaScript naming a defined function, so neither
jest (node environment, no DOM) nor a linter objects to it; at runtime `$()`
returns `null` and the next property access throws, which inside a view's
`init()` aborts the rest of `src/popup/index.js` `init()` and leaves the popup
blank.
It runs with no browser, so unlike the e2e suites it fits inside `make check`,
and it covers every view rather than the ones some test happens to open. It only
sees literal arguments: a call like `$(containerId)` is invisible to it, and a
lookup naming the wrong existing element is valid by construction. Both of those
are the browser suites' job.
## Rationale ## Rationale
@@ -382,60 +511,46 @@ on the next event. Two consequences shape every recurring job in the background:
- `setInterval` and `setTimeout` are useless. They are destroyed with the - `setInterval` and `setTimeout` are useless. They are destroyed with the
worker, so a job scheduled that way runs until the first idle period and never worker, so a job scheduled that way runs until the first idle period and never
again. Both recurring jobs — the 60-second balance refresh and the 24-hour again. The one recurring job — the 60-second balance refresh — is scheduled
phishing blocklist refresh — are scheduled through the extension alarms API through the extension alarms API (`src/shared/alarms.js`) instead. The browser
(`src/shared/alarms.js`) instead. The browser holds the schedule and wakes the holds the schedule and wakes the worker to deliver it. Alarm periods are
worker to deliver it. Alarm periods are clamped to a one-minute minimum, so clamped to a one-minute minimum, so the balance refresh is expressed as
the balance refresh is expressed as exactly one minute and nothing is silently exactly one minute and nothing is silently slowed down.
slowed down.
- Module-level variables do not survive either. Anything that must be remembered - Module-level variables do not survive either. Anything that must be remembered
across a restart goes in extension storage, including the timestamp of the across a restart goes in extension storage. `localStorage` does not exist in a
last phishing list fetch: without it a revived worker would either re-fetch on service worker at all — the one remaining user of it, `src/shared/ens.js`,
every wake or, with a naive in-memory guard, never notice that an update is runs only in the popup and is marked as such.
due. `localStorage` does not exist in a service worker at all — the one
remaining user of it, `src/shared/ens.js`, runs only in the popup and is
marked as such.
Both jobs also carry a freshness guard, and a guard must never be timed to the The job also carries a freshness guard, and a guard must never be timed to the
alarm period it gates. Each guard is measured from the moment the last run alarm period it gates. The guard is measured from the moment the last run
finished, which is one run-duration after the alarm that started it, so a guard finished, which is one run-duration after the alarm that started it, so a guard
of exactly one period vetoes the very next tick and the real cadence becomes two of exactly one period vetoes the very next tick and the real cadence becomes two
periods. The two jobs solve this differently, because their guards exist for periods. The balance refresh guard exists to skip work an open popup has already
different reasons: done — the popup refreshes every 10 seconds and stamps the same field — and that
has to keep applying on the scheduled tick, so the guard is shortened to half
the alarm period rather than bypassed: comfortably above the popup's 10 seconds,
so an open popup still suppresses the background job, and comfortably below the
60-second period, so the schedule always wins.
- The phishing refresh has a 24-hour cache TTL whose job is to keep the worker Retiring a job means clearing its alarm, not just deleting its handler. The
off the network on the wakes between scheduled refreshes — Chrome revives the browser keeps an alarm until something removes it, so an install that once ran
worker every ~30 seconds while the browser is busy, and every revival runs the the version which created it goes on being woken on that schedule forever. Names
startup path. The scheduled alarm tick is not one of those wakes, so it that are no longer handled are listed in `OBSOLETE_ALARMS` and cleared on every
bypasses the TTL and fetches unconditionally. Shortening the TTL instead would start; the 24-hour phishing blocklist refresh is there, retired when the runtime
not work: the startup path re-checks it on every wake, so a shorter TTL simply fetch was removed.
becomes the real refresh rate.
- The balance refresh guard exists to skip work an open popup has already done —
the popup refreshes every 10 seconds and stamps the same field. That has to
keep applying on the scheduled tick, so the guard is shortened to half the
alarm period instead of bypassed: comfortably above the popup's 10 seconds, so
an open popup still suppresses the background job, and comfortably below the
60-second period, so the schedule always wins.
Two timestamps are persisted for the phishing list, not one. `lastFetchTime` The startup path (`ensureRecurringAlarms()`) runs on `onInstalled`, on
records a fetch that produced a usable delta and drives the TTL. `onStartup`, and at the top level of the worker, so every way the background
`lastAttemptTime` records that the network was contacted at all, and is written context can start re-establishes the schedule. On a fresh install more than one
even when the result is unusable — a failed request, or a delta over the 256 KiB of those fires, so they share a single in-flight run rather than racing. It is
cap. Without it those cases leave no freshness mark and the worker re-downloads idempotent: an alarm that already exists with the period the code asks for is
the full blocklist on every wake, indefinitely; with it, unscheduled retries are left alone, because re-creating one restarts its schedule and a busy extension
floored at one hour. Both are discarded on load if they are in the future, since would push the next fire out indefinitely. An alarm carrying a different period
a stamp from a skewed clock or a restored backup would otherwise suppress — one created by an earlier version — is re-created once, or a period changed in
updates until that time arrives, permanently and with no way out. a new release would never reach an existing install.
The startup path (`ensureRecurringAlarms()` plus the phishing list init) runs on Nothing is fetched when the worker starts. A wake costs no network traffic at
`onInstalled`, on `onStartup`, and at the top level of the worker, so every way all, which is what the phishing blocklist being vendored at build time bought.
the background context can start re-establishes the schedule. On a fresh install
more than one of those fires, so they share a single in-flight run rather than
racing. It is idempotent: an alarm that already exists with the period the code
asks for is left alone, because re-creating one restarts its schedule and a busy
extension would push the next fire out indefinitely. An alarm carrying a
different period — one created by an earlier version — is re-created once, or a
period changed in a new release would never reach an existing install.
Firefox uses Manifest V2 with a persistent background page, where timers would Firefox uses Manifest V2 with a persistent background page, where timers would
survive. Both browsers are built from one bundle and both take the alarm path, survive. Both browsers are built from one bundle and both take the alarm path,
@@ -538,6 +653,27 @@ Both are click-copyable. Truncating to 4 decimals in summary views is acceptable
for scannability, but the detail view must never discard precision — it is the for scannability, but the detail view must never discard precision — it is the
one place the user can always use to verify exact details. one place the user can always use to verify exact details.
#### Partial USD totals
Prices are fetched for the top 25 tokens only, so an address can hold assets the
extension has no price for. Worth zero and worth an unknown amount are different
facts and are never collapsed into one number. `getAddressValue()` in
`src/shared/prices.js` returns `{ usd, partial }` — the value of the priced
holdings, and whether an unpriced holding was left out of it — and every screen
renders it through `formatAddressTotal()`, so the wording cannot drift:
- Nothing knowable (testnet, or before the first price fetch): no total line.
- Everything priced: `Total: $5,500.00`.
- Part priced: `Total: $3,000.00 plus unpriced tokens` — the figure is real as
far as it goes and is kept, named as a floor rather than the total.
- Nothing priced but something held: `Total: unpriced tokens only`. No figure,
because the only figure available would be the `$0.00` sum of an empty set,
and on the address-removal confirmation that sits directly under "This address
holds a balance."
The per-token balance lines are unaffected: each shows its quantity, and a USD
column that is blank for a token with no price.
#### Language & Labeling #### Language & Labeling
All user-facing text avoids unnecessary jargon wherever possible: All user-facing text avoids unnecessary jargon wherever possible:
@@ -673,7 +809,9 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
- **When**: At least one wallet exists. This is the root screen. - **When**: At least one wallet exists. This is the root screen.
- **Elements**: - **Elements**:
- Active address ETH balance (large) + USD value in parentheses - Active address ETH balance (large) + USD value in parentheses
- "Total:" USD value across ETH and every token shown for the active address - "Total:" USD value across ETH and every token shown for the active
address, written by `formatAddressTotal()` — see
[Partial USD totals](#partial-usd-totals)
- Active address (color dot, full address, etherscan link, tap to copy) - Active address (color dot, full address, etherscan link, tap to copy)
- Send / Receive quick-action buttons, both acting on the active address - Send / Receive quick-action buttons, both acting on the active address
- ETH/USD price display - ETH/USD price display
@@ -735,7 +873,7 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
- Title: "Wallet Name — Address N" - Title: "Wallet Name — Address N"
- ENS name (if resolved, bold above the address) - ENS name (if resolved, bold above the address)
- Full address (color dot, etherscan link, tap to copy) - Full address (color dot, etherscan link, tap to copy)
- USD total for address - USD total for address (see [Partial USD totals](#partial-usd-totals))
- Balance list: ETH + the ERC-20 tokens shown for this address (4 decimal - Balance list: ETH + the ERC-20 tokens shown for this address (4 decimal
places, USD inline). Each balance row is clickable → **AddressToken** places, USD inline). Each balance row is clickable → **AddressToken**
- Send / Receive / + Token buttons and a "···" menu button - Send / Receive / + Token buttons and a "···" menu button
@@ -1101,11 +1239,13 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
xprv wallet has no recovery phrase to re-import. xprv wallet has no recovery phrase to re-import.
- A warning when the address holds anything, ETH or any tracked ERC-20, - A warning when the address holds anything, ETH or any tracked ERC-20,
followed by the holdings themselves via `balanceLinesForAddress()` and the followed by the holdings themselves via `balanceLinesForAddress()` and the
USD total via `getAddressValueUsd()`. The sentence names no figure of its USD total via `formatAddressTotal()` (see
own: the lines round to four decimals, so a sentence built from a rounded [Partial USD totals](#partial-usd-totals)). The sentence names no figure
number would report `0.0000 ETH` for an address holding real money. The of its own: the lines round to four decimals, so a sentence built from a
predicate is `addressHoldsFunds()` in `src/popup/views/helpers.js`, rounded number would report `0.0000 ETH` for an address holding real
unrounded and token-aware. A balance is a warning, never a refusal. money. The predicate is `addressHoldsFunds()` in
`src/popup/views/helpers.js`, unrounded and token-aware. A balance is a
warning, never a refusal.
- The rule that a wallet always keeps at least one address, and that - The rule that a wallet always keeps at least one address, and that
removing the last one means deleting the wallet from Settings removing the last one means deleting the wallet from Settings
- Error line - Error line
@@ -1175,7 +1315,11 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
opening the window, so the screen shows a complete transaction and the signed opening the window, so the screen shows a complete transaction and the signed
artifact can be compared with it field for field. A request that cannot be artifact can be compared with it field for field. A request that cannot be
populated — unreachable node, reverting gas estimate — opens no window and is populated — unreachable node, reverting gas estimate — opens no window and is
failed back to the site. failed back to the site. Only one transaction approval exists at a time:
populating fixes the nonce, so a second `eth_sendTransaction` arriving while
one is unanswered is refused with EIP-1193 code `-32002` rather than being
populated at the same nonce. It opens no window and takes no nonce, and the
site can send it again once the pending one is answered.
- **Elements**: - **Elements**:
- "Transaction Request" heading - "Transaction Request" heading
- Phishing warning banner (shown when the hostname is on the phishing - Phishing warning banner (shown when the hostname is on the phishing
@@ -1269,17 +1413,6 @@ What the extension does NOT do:
In addition to the three user-configurable services above (RPC endpoint, In addition to the three user-configurable services above (RPC endpoint,
CoinDesk price API, and Blockscout API), AutistMask also contacts: CoinDesk price API, and Blockscout API), AutistMask also contacts:
- **Phishing domain blocklist**: A community-maintained phishing domain
blocklist is vendored into the extension at build time. At runtime, the
extension fetches the live list once every 24 hours to detect newly added
domains, plus once on a start where the list is more than 24 hours old. Only
the delta (domains not already in the vendored list) is kept in memory,
keeping runtime memory usage small. The delta and the timestamp of the fetch
that produced it are persisted to extension storage if the record is under 256
KiB; an oversized delta is dropped along with its timestamp, so a later start
fetches again rather than claiming freshness for data it no longer holds. A
fetch that fails, or one whose delta was too large to store, is not retried
more than once an hour outside the 24-hour schedule.
- **Etherscan address labels**: When confirming a transaction, the extension - **Etherscan address labels**: When confirming a transaction, the extension
performs a best-effort lookup of the recipient address on Etherscan to check performs a best-effort lookup of the recipient address on Etherscan to check
for phishing/scam labels. This is a direct page fetch with no API key; the for phishing/scam labels. This is a direct page fetch with no API key; the
@@ -1550,17 +1683,25 @@ indexes it as a real token transfer.
AutistMask protects users from known phishing sites when they connect their AutistMask protects users from known phishing sites when they connect their
wallet or approve transactions/signatures. A community-maintained domain wallet or approve transactions/signatures. A community-maintained domain
blocklist is vendored into the extension at build time, providing immediate blocklist is vendored into the extension at build time and checked entirely
protection without any network requests. At runtime, the extension fetches the locally: no network request is made for it, ever, so nobody learns which sites
live list once every 24 hours and keeps only the delta (newly added domains not the user connects to and no third party decides what this wallet warns about.
in the vendored list) in memory. This architecture keeps runtime memory usage
small while ensuring fresh coverage of new phishing domains.
The 24-hour cadence is an alarm, not a timer; the alarm tick fetches The trade is freshness. The shipped list is exactly as current as the last
unconditionally rather than re-checking the 24-hour cache TTL that gates the vendoring run that was released, so a domain added upstream reaches users in the
startup path; and the fetch timestamps live in extension storage rather than in next release rather than within a day. Refreshing it is `make vendor-blocklist`,
module variables — see [Background scheduling](#background-scheduling) for why which fetches a hash-pinned upstream commit, verifies the sha256 of the bytes it
all three are required. was served, and rewrites `src/shared/phishingBlocklist.json`; the diff is
committed and ships with the next version.
The artifact holds digests, not domain names: sha256 truncated to 64 bits, one
entry per 16 hex characters, concatenated in sorted order into a single string
(`src/shared/domainHash.js`). A lookup hashes the hostname and its parent
domains and binary-searches that string, so nothing is built at module load —
which matters on MV3, where the worker re-evaluates the module on every wake —
and the file is 1.7 MB rather than 8.7 MB. Storing digests is also what makes a
list assembled elsewhere shippable here at all: the extension carries no
plaintext list of anyone's domain names.
When a dApp on a blocklisted domain requests a wallet connection, transaction When a dApp on a blocklisted domain requests a wallet connection, transaction
approval, or signature, the approval popup displays a prominent red warning approval, or signature, the approval popup displays a prominent red warning
@@ -1657,17 +1798,23 @@ covered by the GPL-3.0 license above. These files, their copyright holders, and
their licenses are: their licenses are:
| File | Source | Copyright | License | | File | Source | Copyright | License |
| ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------- | -------------------------------------------------------------- | | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | --------------------------------- | -------------------------------------------------------------- |
| `src/shared/phishingBlocklist.json` | `eth-phishing-detect` community-maintained phishing domain blocklist, vendored from its `src/config.json` | Copyright (c) 2018 kumavis | [DBAD (Don't Be a Dick)](https://github.com/philsturgeon/dbad) | | `src/shared/phishingBlocklist.json` | `eth-phishing-detect` community-maintained phishing domain blocklist, derived from its `src/config.json` | Copyright (c) 2018 kumavis | [DBAD (Don't Be a Dick)](https://github.com/philsturgeon/dbad) |
| `src/shared/scamlist.js` (address data from MyEtherWallet) | [ethereum-lists](https://github.com/MyEtherWallet/ethereum-lists) `addresses-darklist.json` | Copyright (c) 2020 MyEtherWallet | MIT | | `src/shared/scamlist.js` (address data from MyEtherWallet) | [ethereum-lists](https://github.com/MyEtherWallet/ethereum-lists) `addresses-darklist.json` | Copyright (c) 2020 MyEtherWallet | MIT |
| `src/shared/scamlist.js` (address data from EtherScamDB) | [EtherScamDB](https://github.com/MrLuit/EtherScamDB) `scams.yaml` | Copyright (c) 2018 Luit Hollander | MIT | | `src/shared/scamlist.js` (address data from EtherScamDB) | [EtherScamDB](https://github.com/MrLuit/EtherScamDB) `scams.yaml` | Copyright (c) 2018 Luit Hollander | MIT |
The full license texts for these third-party files are included in the The full license texts for these third-party files are included in the
[LICENSE](LICENSE) file. The `eth-phishing-detect` row carries no repository [LICENSE](LICENSE) file. The `eth-phishing-detect` row carries no repository
link because the upstream is hosted under a competitor's organization name, link because the upstream is hosted under a competitor's organization name,
which project policy keeps out of code and documentation; the vendored copy and which project policy keeps out of code and documentation.
the runtime refresh both come from that upstream, whose URL is the `script/vendor-blocklist` is the single definition and the only file that spells
`BLOCKLIST_URL` constant in `src/shared/phishingDomains.js`. the name in prose: it is build-time tooling, never shipped, and it records the
exact URL, the commit it is pinned to and the sha256 of the bytes that commit
serves, because a source reference nobody can verify is not a source reference.
`script/check-censored` reads the name back out of that one file and fails the
build wherever else it appears, save for three shipped-code literals it cannot
avoid — each permitted only at the one path that carries it, and listed in that
script's header.
## Author ## Author

199
TODO.md
View File

@@ -32,19 +32,69 @@ The backlog lives on the
[Gitea tracker](https://git.eeqj.de/sneak/AutistMask/issues), which is [Gitea tracker](https://git.eeqj.de/sneak/AutistMask/issues), which is
authoritative; this file does not duplicate it. Full policy file set present. authoritative; this file does not duplicate it. Full policy file set present.
Real-browser end-to-end suites (`make test-e2e` for Chrome, Real-browser end-to-end suites (`make test-e2e` for Chrome,
`make test-e2e-firefox` for Firefox) now sit alongside `make check`, which `make test-e2e-firefox` for Firefox) sit alongside `make check`, which now does
cannot see a runtime `ReferenceError` in a popup view. static analysis as well as formatting, and `.gitea/workflows/e2e.yml` runs both
of them on every push.
# Next Step # Next Step
Land [#152](https://git.eeqj.de/sneak/AutistMask/issues/152): add ESLint to Pre-1.0 security review of the extension (key handling, DEBUG mode policy, RPC
`script/lint`. `make check` is `prettier --check` only today and cannot catch input validation) before any 1.0rc tag. Individual filed issues are parts of it,
undefined identifiers, which is how but the review is broader than any of them.
[#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-17: The Settings screen is driven in a browser, and every element id
the popup looks up is checked statically. Nothing exercised Settings in the
e2e suite, and jest runs with no DOM, so the densest run of `$("...")` lookups
in the codebase was unverified at runtime. Seven new cases in
`tests/e2e/run.js` reach Settings, assert the About well and the wallet list
were actually written, assert the four Token Spam Protection checkboxes are
real checkboxes defaulted on, and assert the theme and network selectors offer
the choices `src/shared/networks.js` and `index.html` define. The selectors
are then driven to `dark` and `sepolia` — neither is the first `<option>`, so
neither can be read back from the markup with no JavaScript involved — and
reasserted after a popup reopen before being restored the same way, and one
spam filter is toggled off and back on across a reopen each way. Those round
trips run the change handler, `saveState()`, `loadState()` and the
`init()`/`show()` assignments rather than just looking at the screen. `show()`
no longer guards its `settings-network` lookup with `if (networkSelect)`: a
missing element must fail loudly, which is the whole failure mode this unit
exists to catch. Each group records a coverage key and a final case demands
the exact set, so a shortened or skipped section reddens the run instead of
shrinking it. `tests/popupElementIds.test.js` is the general half and runs in
`make check` with no browser: every literal id reached through `$()`,
`document.getElementById()`, `showError()`/`hideError()` and `showView()` must
exist in `src/popup/index.html`, which no id in `index.html` may define twice.
Demonstrated on four deliberate breaks — a typo'd id (both halves red), a
handler bound to the wrong but existing element (only the functional e2e case
red), a typo in a view no browser suite opens (only the static guard red), and
the deletion of both persisted-value assignments in `settings.js` (only the
selector round-trip case red)
([#229](https://git.eeqj.de/sneak/AutistMask/issues/229)).
- 2026-08-17: The phishing blocklist is vendored at build time and censored, and
the runtime fetch is gone
([#219](https://git.eeqj.de/sneak/AutistMask/issues/219)).
`script/vendor-blocklist` fetches upstream at a pinned commit, verifies the
sha256 of the bytes it was served, and writes
`src/shared/phishingBlocklist.json` as truncated sha256 digests rather than
domain names — which is what removes the competitor's name from a list that
carried it 6,475 times, without dropping a single one of those domains.
`script/check-censored` runs in `make check` and again against `dist/` at the
end of every build, each permitted occurrence scoped to the one path allowed
to carry it; the name now appears only in the vendoring script, which defines
it once, in the provider-shim identifiers in `src/content/inpage.js`, and in
one ERC-20's on-chain name in `src/shared/tokenList.js`. Removing the fetch
retired the delta, the persistence and the 24-hour alarm from
[#158](https://git.eeqj.de/sneak/AutistMask/issues/158), and retired alarms
are now cleared rather than left running on existing installs. Two
consequences, both deliberate: the list no longer self-updates, so it is as
fresh as the last vendoring run that was released; and re-vendoring from
current upstream took it from 231,357 stale entries to 105,721 current ones,
because upstream prunes and the vendored snapshot never did. `dist/` fell from
18.9 MB to 8.9 MB. The e2e suite now drives the warning end to end from a real
blocklisted origin, and its service-worker interception canary has a new
anchor, because the startup fetch it used to watch for no longer exists.
- 2026-08-17: One wording for an empty password field on every screen that asks - 2026-08-17: One wording for an empty password field on every screen that asks
for one. The private key export screen said "Password is required." where the for one. The private key export screen said "Password is required." where the
other five say "Please enter your password.", the same one-condition-two- other five say "Please enter your password.", the same one-condition-two-
@@ -58,6 +108,41 @@ undefined identifiers, which is how
screen's container holds at 20px with the following section at the same offset screen's container holds at 20px with the following section at the same offset
for the old string, the new string and the empty reserved state for the old string, the new string and the empty reserved state
([#265](https://git.eeqj.de/sneak/AutistMask/issues/265)). ([#265](https://git.eeqj.de/sneak/AutistMask/issues/265)).
- 2026-08-17: One shared extension-API module,
[`src/shared/browserApi.js`](src/shared/browserApi.js), is the only place in
the tree that names `browser` or `chrome`. Every call site returns a promise;
`runtime.lastError` is gone. The same commit gives the Firefox suite the four
dApp round trips — `eth_requestAccounts`, `personal_sign`,
`eth_sendTransaction` and a closed approval window rejecting with EIP-1193
4001 — against a page and a JSON-RPC node served from loopback, which survives
`--network none`. **The premise of
[#153](https://git.eeqj.de/sneak/AutistMask/issues/153) does not survive that
harness**: Firefox's `browser.*` honours a trailing Chrome-style callback and
populates `runtime.lastError`, both measured directly on Firefox 153.0.3, and
all four flows pass against the unconverted code. What landed is a uniformity
and coverage change, not a repair of a broken target. `storageGet()` and
`storageSet()` **reject** where `storage.local` is absent rather than
resolving `{}` and a no-op write — they carry the wallet, and defaulting would
read an existing wallet back as none. The one caller that genuinely degraded,
[`src/shared/phishingDomains.js`](src/shared/phishingDomains.js), took
`storageLocal()` directly and kept its own null check; it stores nothing at
all as of [#219](https://git.eeqj.de/sneak/AutistMask/issues/219) above.
- 2026-08-17: An address total no longer reports `$0.00` for holdings it cannot
price. Prices exist for the top 25 tokens only, so the priced-only sum was
printed as the total and an address holding nothing but unpriced ERC-20s was
shown as worth nothing — directly under "This address holds a balance." on the
address-removal confirmation. `getAddressValue()` in `src/shared/prices.js`
now returns `{ usd, partial }`, keeping worth-zero and worth-an-unknown-amount
apart the way an absent `holders_count` is kept apart from a count of zero,
and every screen renders it through the one `formatAddressTotal()`: the figure
when it covers everything, the figure marked `plus unpriced tokens` when it
covers part, and `Total: unpriced tokens only` when it would cover nothing.
Home, AddressDetail and the removal confirmation all read it, and
`getWalletValue()`/`getTotalValue()` carry `partial` up. Covered by
`tests/addressValue.test.js` — the only-unpriced, genuinely-zero and
fully-priced cases at the helper and at both call sites that return their
markup — demonstrated failing first
([#261](https://git.eeqj.de/sneak/AutistMask/issues/261)).
- 2026-08-17: `README.md` no longer advertises a defect the wallet does not - 2026-08-17: `README.md` no longer advertises a defect the wallet does not
have. The End-to-End Tests section listed the EIP-1193 code being dropped in have. The End-to-End Tests section listed the EIP-1193 code being dropped in
the last hop into the page as a standing limit of the dApp coverage; that the last hop into the page as a standing limit of the dApp coverage; that
@@ -67,6 +152,31 @@ undefined identifiers, which is how
stubbed RPC and the unobservable toolbar popup — were checked against the stubbed RPC and the unobservable toolbar popup — were checked against the
current `src/content/inpage.js` and `tests/e2e/` and left as they are current `src/content/inpage.js` and `tests/e2e/` and left as they are
([#285](https://git.eeqj.de/sneak/AutistMask/issues/285)). ([#285](https://git.eeqj.de/sneak/AutistMask/issues/285)).
- 2026-08-17: One transaction approval at a time. Populating in the background
before the window opens is what makes the displayed object the verified
object, and it also fixes the nonce: two `eth_sendTransaction` calls populated
concurrently took the same nonce from a node that had seen neither broadcast,
and the second could then never be sent, because the only way to give it a
fresh nonce is to populate it again after the user has read the old one off
the screen. A second request is now refused with EIP-1193 `-32002` while one
is unanswered — the slot is taken immediately before population, after the
authorization checks, so no second nonce is allocated, no second window opens,
and a page the wallet refuses anyway cannot hold the slot against the
connected site. The slot is freed at `settleApproval()`, the single point an
approval is retired, so every path that ends an approval ends the hold with
it; an approval whose window is gone and whose attempt has failed is settled
there rather than left waiting on a window that no longer exists, and an
approval window that could not be opened at all is answered with `-32603`
instead of holding the page's promise open. Signature approvals are not gated,
consuming no nonce. A collision that does happen is also reported accurately
now: a broadcast the node refused for the nonce, and an approval carrying a
nonce this worker has already broadcast for that address on that chain (caught
before the node is asked at all), both say the transaction did not reach the
network and to send it again, instead of warning that it may have sent. The
record is keyed by chain as well as address, because nonce spaces are per
chain and low nonces overlap across them. `already known` deliberately keeps
the ambiguous wording, because a node that says it has the transaction has it
([#271](https://git.eeqj.de/sneak/AutistMask/issues/271)).
- 2026-08-14: The parts of the - 2026-08-14: The parts of the
[#150](https://git.eeqj.de/sneak/AutistMask/issues/150) and [#150](https://git.eeqj.de/sneak/AutistMask/issues/150) and
[#151](https://git.eeqj.de/sneak/AutistMask/issues/151) definition of done the [#151](https://git.eeqj.de/sneak/AutistMask/issues/151) definition of done the
@@ -83,6 +193,74 @@ undefined identifiers, which is how
on the real clipboard, read back after a sentinel write. Each of the four was on the real clipboard, read back after a sentinel write. Each of the four was
demonstrated failing against a deliberately broken build demonstrated failing against a deliberately broken build
([#188](https://git.eeqj.de/sneak/AutistMask/issues/188)). ([#188](https://git.eeqj.de/sneak/AutistMask/issues/188)).
- 2026-08-14: `make check` does static analysis. `script/lint` ran
`prettier --check .`, byte-identical to `script/fmt-check`, so a wallet with
two shipped used-but-not-imported crashes behind it was green. ESLint is now
pinned in `package.json` with `@eslint/js` recommended as the base, flat
config in `eslint.config.js`, `no-undef` and `no-unused-vars` error-level, and
globals declared per tree — browser for the popup and content scripts, service
worker for `src/background/` and `src/shared/`, jest for `tests/`, node for
`build.js`. It found 41 unused bindings and 53 undefined identifiers; all are
fixed, and dropping a call to an unimported `foo()` into any `src/` file fails
`make lint`. Linting is also containerized now: `script/lint` builds the
Dockerfile's new `lint` stage, so the ESLint that decides whether this repo is
green is the pinned one and not the host's. The lint stage roughly doubles the
image build, so `script/test`'s hard timeout is now a bound on a hung suite
rather than a wall-clock budget: 30s on the host, where the suite runs in
about 8s, and `AUTISTMASK_TEST_TIMEOUT` raises it inside the image, where a
cold build pays install and contention costs the policy budget never described
([#152](https://git.eeqj.de/sneak/AutistMask/issues/152)).
- 2026-08-14: CI runs the browser end-to-end suites. `.gitea/workflows/e2e.yml`
runs `script/test-e2e` and `script/test-e2e-firefox` as two jobs on every
push, separate from `check`, so `make check` and its 20-second `make test` cap
are untouched. Every browser-level guarantee in this repo — the WASM-under-CSP
check, the recovery-phrase and private-key DOM wipes, the ConfirmTx spend
gate, the dApp approval round trips — was enforced only when a human
remembered to run it by hand. The suites could not run on the runner as they
stood: the runner executes a job in a container against the host's docker
daemon, so `docker run -v "$PWD:/work"` mounts an empty directory (measured),
and the runner image's node cannot install this repo's dependencies. Both
suites now ship the repo to the daemon as a build context and build the
extension inside the pinned image, so docker is the only prerequisite on a
runner or a laptop, and both run the image by ID rather than by tag so
concurrent clones cannot swap it. The jobs report rather than gate — this repo
configures no branch protection, and the Chrome suite is measurably flaky
under load, filed as [#287](https://git.eeqj.de/sneak/AutistMask/issues/287)
rather than papered over
([#259](https://git.eeqj.de/sneak/AutistMask/issues/259)).
- 2026-08-14: A background message handler that throws now rejects the page
instead of hanging it. `handleRpc(...).then(sendResponse)` had no `.catch()`,
and `sendResponse` is the only thing that settles the dApp's
`window.ethereum.request()` promise — so any throw inside `handleRpc` left
that promise pending forever, with no error and no timeout, indistinguishable
from a slow wallet. It now answers `{ code: -32603, message }` (the JSON-RPC
internal error EIP-1474 defines and EIP-1193 defers to; no EIP-1193 4xxx code
describes "the wallet broke" and none was invented) and logs the method and
the throw to the background console rather than swallowing them. The two async
IIFEs behind `AUTISTMASK_TX_RESPONSE` and `AUTISTMASK_SIGN_RESPONSE` were the
same shape one level down — every statement inside a `try`, but a throw out of
a `catch` block escaping unhandled — and each got a last-resort `.catch()`
settling the approval through `settleApproval()` and answering the popup. The
transaction one tracks which phase it escaped from and reports that, so an
escape before `broadcastTransaction()` says the request is gone rather than
that it may still have reached the network. Every other handler on the path is
synchronous. All four are driven by real failures — a rejecting storage read,
and a failure classifier that throws while classifying a genuine verification
or broadcast failure — and were demonstrated failing first, the RPC one with
`sendResponse` at zero calls
([#280](https://git.eeqj.de/sneak/AutistMask/issues/280)).
- 2026-08-14: Approving a site connection is no longer a race against the popup
closing. The decision now rides the approval port the popup already holds,
which is the same channel the close disconnects, so it is delivered ahead of
that disconnect however fast the teardown is; `windows.onRemoved` no longer
decides a site approval whose port is connected, since that event is ordered
against nothing either. Rejecting and closing without deciding both still
report a rejection, and the popup delays its own close by nothing. The e2e
harness's deferred-`window.close()` accommodation is gone with it, so the two
site-prompt tests now drive the shipped decide-then-close in a real Chromium;
against the unfixed code the approval came back to the page as
`{"settled":"rejected","code":4001}`
([#275](https://git.eeqj.de/sneak/AutistMask/issues/275)).
- 2026-08-12: EIP-1193 error codes now reach the page. `src/content/inpage.js` - 2026-08-12: EIP-1193 error codes now reach the page. `src/content/inpage.js`
rebuilt every failure as `new Error(error.message)`, so the code the rebuilt every failure as `new Error(error.message)`, so the code the
background produced and the content script relayed intact was dropped in the background produced and the content script relayed intact was dropped in the
@@ -403,12 +581,5 @@ undefined identifiers, which is how
Only work that has no issue of its own belongs here; everything else is on the Only work that has no issue of its own belongs here; everything else is on the
tracker. tracker.
- Pre-1.0 security review of the extension (key handling, DEBUG mode policy, RPC
input validation) before any 1.0rc tag. Individual filed issues are parts of
it, but the review is broader than any of them.
- Decide whether docker-in-docker makes `make test-e2e` and
`make test-e2e-firefox` runnable in the Gitea workflow. Extending the Chrome
suite itself is tracked as
[#183](https://git.eeqj.de/sneak/AutistMask/issues/183).
- Cut 1.0.0 once the milestone is empty, then continue tagging as milestones - Cut 1.0.0 once the milestone is empty, then continue tagging as milestones
land. land.

View File

@@ -63,7 +63,7 @@ function getBuildInfo() {
commitHash = execSync("git rev-parse --short HEAD", { commitHash = execSync("git rev-parse --short HEAD", {
encoding: "utf8", encoding: "utf8",
}).trim(); }).trim();
} catch (_) { } catch {
// not a git repo or git not available // not a git repo or git not available
} }
let commitHashFull = "unknown"; let commitHashFull = "unknown";
@@ -71,7 +71,7 @@ function getBuildInfo() {
commitHashFull = execSync("git rev-parse HEAD", { commitHashFull = execSync("git rev-parse HEAD", {
encoding: "utf8", encoding: "utf8",
}).trim(); }).trim();
} catch (_) { } catch {
// not a git repo or git not available // not a git repo or git not available
} }
return { return {

View File

@@ -120,25 +120,6 @@ 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 balances, no identifying information. As with any request, CoinDesk sees your IP
address. 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) **Etherscan address labels** (`etherscan.io`; `sepolia.etherscan.io` on Sepolia)
When you review a send, AutistMask fetches the recipient's public Etherscan When you review a send, AutistMask fetches the recipient's public Etherscan
@@ -367,8 +348,12 @@ confirmation screen. It contains only addresses involved in fraud -- it is not a
sanctions list. sanctions list.
**Phishing domain warnings.** Sites asking to connect or to have something **Phishing domain warnings.** Sites asking to connect or to have something
approved are checked against the phishing domain blocklist described under approved are checked against a community-maintained list of known phishing
External Services, and flagged with a red banner if they match. domains, and flagged with a red banner if they match. The list is built into the
extension: the check is entirely local, so nobody is told which sites you visit,
and it works offline. It is also only as current as the release you are running
— a domain added to the list upstream reaches you in the next version of the
extension, not the same day.
The first four filters can be individually disabled in Settings if you prefer to The first four filters can be individually disabled in Settings if you prefer to
see everything unfiltered. see everything unfiltered.

162
eslint.config.js Normal file
View File

@@ -0,0 +1,162 @@
// ESLint flat config. Static analysis for make check; formatting stays with
// prettier (script/fmt-check), so nothing here touches style.
//
// The sources are CommonJS and are bundled per entrypoint by build.js, so the
// globals differ by tree and are declared per tree below. Getting that wrong in
// either direction defeats the point: too few globals buries a real no-undef in
// false positives, too many hides the next unimported identifier.
const js = require("@eslint/js");
const globals = require("globals");
// The extension APIs. MV3 Chrome exposes `chrome`; Firefox exposes both, and
// the code feature-detects between them.
const extensionGlobals = {
chrome: "readonly",
browser: "readonly",
};
const commonjs = {
ecmaVersion: 2024,
sourceType: "commonjs",
};
module.exports = [
{
ignores: ["dist/", "node_modules/"],
},
js.configs.recommended,
{
rules: {
// The two rules this config exists for. Both are already
// error-level in the recommended set; restated so a future
// recommended-set change cannot silently downgrade them.
"no-undef": "error",
// `_`-prefixed arguments are the deliberate "present for the
// interface, unused here" marker: the popup views share one
// init(ctx) signature and three of the eight do not read ctx.
// An unused catch binding is written `catch {`, which the repo
// already does, so caught errors stay checked.
"no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
// Off tree-wide: it requires every rethrow to carry `{ cause }`,
// at 3 sites today (src/shared/balances.js 207 and 215,
// tests/e2e/firefox/run.js 131). That is a change to what the
// wallet's error paths actually throw, and it is a decision of its
// own rather than a side effect of turning a linter on — so it is
// off everywhere, including for new code, until that decision is
// made. Unlike no-useless-assignment below, this is not an
// accommodation of particular sites and must not be scoped to
// them.
"preserve-caught-error": "off",
},
},
// no-useless-assignment stays on everywhere except the two files that
// wipe decrypted key material: the `password = null` and
// `decryptedSecret = null` assignments after use are dead by construction
// — that is what a best-effort wipe is — and the rule's fix is to delete
// the wipe. 9 sites: approval.js 582, 593, 618, 648, 692, 703, 728, 764
// and confirmTx.js 459. Everything else in the tree is still checked, so
// an ordinary dead store elsewhere is still an error.
{
files: ["src/popup/views/approval.js", "src/popup/views/confirmTx.js"],
rules: {
"no-useless-assignment": "off",
},
},
// Popup and content scripts: page/window context.
{
files: ["src/popup/**/*.js", "src/content/**/*.js"],
languageOptions: {
...commonjs,
globals: { ...globals.browser, ...extensionGlobals },
},
},
// MV3 background: a service worker, with no window and no document.
{
files: ["src/background/**/*.js"],
languageOptions: {
...commonjs,
globals: { ...globals.serviceworker, ...extensionGlobals },
},
},
// src/shared is bundled into both, so it may only use what both provide:
// the service worker globals are the intersection, plus the extension APIs.
{
files: ["src/shared/**/*.js"],
languageOptions: {
...commonjs,
globals: { ...globals.serviceworker, ...extensionGlobals },
},
},
// src/shared/ens.js is the documented exception to the line above: its own
// header says POPUP ONLY, it caches in localStorage, and only popup views
// require it. Linting it as a service worker would be wrong about the file.
{
files: ["src/shared/ens.js"],
languageOptions: {
...commonjs,
globals: { ...globals.browser, ...extensionGlobals },
},
},
// Unit tests: jest on node.
{
files: ["tests/**/*.test.js"],
languageOptions: {
...commonjs,
globals: { ...globals.node, ...globals.jest },
},
},
// The build script is a plain node program.
{
files: ["build.js"],
languageOptions: {
...commonjs,
globals: { ...globals.node },
},
},
// The helpers the script/ entrypoints call: plain node programs too, run
// from a shell script rather than from yarn, and never bundled.
{
files: ["script/lib/**/*.js"],
languageOptions: {
...commonjs,
globals: { ...globals.node },
},
},
// The e2e harnesses are node programs that also carry, inline, the
// callbacks they ship into the browser via page.evaluate — so both
// contexts really are present in the same file and both sets of globals
// are in scope somewhere in it.
{
files: ["tests/e2e/**/*.js"],
languageOptions: {
...commonjs,
globals: {
...globals.node,
...globals.browser,
...extensionGlobals,
},
},
},
// This config file itself.
{
files: ["eslint.config.js"],
languageOptions: {
...commonjs,
globals: { ...globals.node },
},
},
];

View File

@@ -9,13 +9,16 @@
"test": "jest --forceExit", "test": "jest --forceExit",
"test:verbose": "jest --forceExit --verbose", "test:verbose": "jest --forceExit --verbose",
"build": "node build.js", "build": "node build.js",
"lint": "prettier --check .", "lint": "eslint . && prettier --check .",
"fmt": "prettier --write .", "fmt": "prettier --write .",
"fmt-check": "prettier --check ." "fmt-check": "prettier --check ."
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "10.0.1",
"@tailwindcss/cli": "^4.2.1", "@tailwindcss/cli": "^4.2.1",
"esbuild": "^0.27.3", "esbuild": "^0.27.3",
"eslint": "10.8.1",
"globals": "17.11.0",
"jest": "^30.2.0", "jest": "^30.2.0",
"playwright-core": "1.56.0", "playwright-core": "1.56.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",

View File

@@ -8,6 +8,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() { main() {
"$SCRIPT_DIR/test" "$SCRIPT_DIR/test"
"$SCRIPT_DIR/test-verify-build" "$SCRIPT_DIR/test-verify-build"
"$SCRIPT_DIR/check-censored"
"$SCRIPT_DIR/lint" "$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check" "$SCRIPT_DIR/fmt-check"
} }

301
script/check-censored Executable file
View File

@@ -0,0 +1,301 @@
#!/bin/sh
# script/check-censored: assert that the competitor name RULES.md bars appears
# nowhere in this repo, and nowhere in the built extension, except where it is
# deliberate. Our own extension to scripts-to-rule-them-all, run from
# script/check and from make build.
#
# Where the name is allowed, and why each one is not negotiable away:
#
# - script/vendor-blocklist. Build-time tooling, never shipped. A pinned
# source reference that does not say what the source is cannot be verified
# by anyone, so it names it. Whole-file exemption.
# - the two provider-shim identifiers in src/content/inpage.js. Protocol
# identifiers dApps feature-detect on; renaming them does not rename them in
# their code, it only stops this wallet working on their sites.
# - the on-chain name of the MUSD ERC-20 in src/shared/tokenList.js. It is not
# what backs symbol-spoof detection — that reads symbol and address — but
# the wallet already surfaces the on-chain name of any token the user holds
# (src/shared/balances.js), and this contract's on-chain name is that
# string, so censoring the repo cannot stop the wallet displaying it.
# Dropping the entry instead would cost the user MUSD spoof detection.
#
# Everything else fails, in the working tree and under dist/. The last two are
# literals rather than whole files, so they are enforced by counting, and each
# literal is scoped to the path allowed to carry it: a file may contain the name
# only as many times as it contains the literals permitted *there*, and zero
# times anywhere else. The emitted bundles carry them too, so a plain "the name
# must not appear in dist/" could never have passed.
#
# The name itself is not written in this file. script/vendor-blocklist is the
# one place in this repo that defines it, and this reads it back out of there —
# so the repo-wide grep this check exists to enforce keeps returning exactly the
# files named above, and this file is not one of them.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Absolute path to this script, resolved before anything cd's anywhere: the
# scan half runs in a re-invocation through xargs, so that the paths it works on
# arrive as arguments and cannot be reshaped by field splitting on the way in.
SELF="$(cd "$(dirname "$0")" && pwd -P)/$(basename "$0")"
# Internal re-entry flag. Not part of the command-line interface.
SCAN_FLAG="--scan-paths"
VENDOR_SCRIPT="$ROOT/script/vendor-blocklist"
# Set by extract_name / make_literals_file.
NAME=""
ALLOWED_LITERALS_FILE=""
FAILED=0
cleanup() {
[ -z "$ALLOWED_LITERALS_FILE" ] || rm -f "$ALLOWED_LITERALS_FILE"
}
trap cleanup EXIT INT TERM
fail() {
echo "check-censored: FAIL: $*" >&2
exit 1
}
# The name, taken from the single place that defines it. A check scanning for a
# pattern it failed to read would pass against anything, so this refuses to
# continue unless it got something that looks like the definition.
extract_name() {
[ -f "$VENDOR_SCRIPT" ] ||
fail "$VENDOR_SCRIPT is missing, and it is where the name being
checked for is defined. Nothing was scanned."
NAME="$(grep -m1 '^UPSTREAM_ORG=' "$VENDOR_SCRIPT" | cut -d'"' -f2)" ||
fail "could not read UPSTREAM_ORG from $VENDOR_SCRIPT. Nothing was
scanned."
case "$NAME" in
"" | *[!A-Za-z0-9]*)
fail "UPSTREAM_ORG in $VENDOR_SCRIPT did not yield a plain name
(got: '$NAME'). Scanning for that would prove nothing. Nothing was
scanned."
;;
esac
}
make_literals_file() {
ALLOWED_LITERALS_FILE="$(mktemp \
"${TMPDIR:-/tmp}/autistmask-censored.XXXXXX")" ||
fail "could not create a temporary file, so nothing was scanned."
}
# The literals $1 may carry, and nothing else may. Each contains the name
# exactly once, which is what makes counting them sound; each is scoped to its
# path, so a file with no business carrying the name fails even when it spells
# it the way shipped code has to. Scoping is the point: permitting these
# literals in any file is what once let this check pass its own prose.
#
# The emitted paths are listed next to the sources they come from. If the
# bundler moves one, this goes red and the new path gets added deliberately,
# rather than a wildcard over dist/ covering whatever lands there.
allowed_literals_for() {
: >"$ALLOWED_LITERALS_FILE"
case "$1" in
src/content/inpage.js | dist/*/src/content/inpage.js)
printf 'is%s\n_%s\n' "$NAME" "$NAME" >"$ALLOWED_LITERALS_FILE"
;;
src/shared/tokenList.js | dist/*/src/background/index.js | \
dist/*/src/popup/index.js)
printf '%s USD\n' "$NAME" >"$ALLOWED_LITERALS_FILE"
;;
esac
}
# How many times does $1 contain the name (TOTAL), and how many of those are one
# of the allowed literals (ALLOWED)? Same discipline the rest of this repo's
# shell checks apply to grep: exit 0 and 1 are answers about the file, anything
# else means the file was not searched and is not an answer at all.
count_matches() {
_cm_status=0
_cm_out="$(grep -a -o -i -F -e "$NAME" -- "$1")" || _cm_status=$?
case "$_cm_status" in
0) TOTAL="$(printf '%s\n' "$_cm_out" | grep -c .)" ;;
1) TOTAL=0 ;;
*)
fail "grep exited $_cm_status reading $1, so the file was never
searched and nothing was established about it. That is a permissions or I/O
fault, not a clean file. Refusing to report success."
;;
esac
if [ "$TOTAL" -eq 0 ]; then
ALLOWED=0
return 0
fi
# No literal is permitted at this path, so every occurrence is a violation.
# Handled here rather than by grep, which is not required to say anything
# useful about an empty pattern file.
if [ ! -s "$ALLOWED_LITERALS_FILE" ]; then
ALLOWED=0
return 0
fi
_cm_status=0
_cm_out="$(grep -a -o -i -F -f "$ALLOWED_LITERALS_FILE" -- "$1")" ||
_cm_status=$?
case "$_cm_status" in
0) ALLOWED="$(printf '%s\n' "$_cm_out" | grep -c .)" ;;
1) ALLOWED=0 ;;
*)
fail "grep exited $_cm_status matching the allowed literals in $1.
Refusing to report success."
;;
esac
}
# The per-path half, run in a re-invocation of this script so it uses the same
# counting as everything else rather than a second copy of it.
scan_paths() {
for _file in "$@"; do
# dist/ arrives absolute (find) and the worktree relative (git
# ls-files). The allowlist is keyed on repo-relative paths, so both
# forms are reduced to one before anything is decided about them.
_rel="$_file"
case "$_rel" in
"$ROOT"/*) _rel="${_rel#"$ROOT"/}" ;;
esac
case "$_rel" in
script/vendor-blocklist) continue ;;
esac
[ -f "$_file" ] || continue
allowed_literals_for "$_rel"
count_matches "$_file"
[ "$TOTAL" -gt "$ALLOWED" ] || continue
FAILED=$((FAILED + 1))
echo "check-censored: $_rel: $TOTAL occurrence(s) of the name," \
"$ALLOWED of them allowed at this path" >&2
grep -a -n -i -F -e "$NAME" -- "$_file" | cut -c1-140 | head -5 >&2
done
[ "$FAILED" -eq 0 ]
}
# Hand a NUL-delimited listing to the scan half. Returns non-zero if any path
# failed, or if the scan could not be run at all.
scan_listing() {
xargs -0 "$SELF" "$SCAN_FLAG" <"$1"
}
# Every file git tracks, plus everything untracked and not ignored: the working
# tree as a reviewer would see it, and never node_modules or dist/ (both are
# ignored; dist/ is walked separately below).
check_worktree() {
_list="$(mktemp "${TMPDIR:-/tmp}/autistmask-censored-tree.XXXXXX")" ||
fail "could not create a temporary file, so nothing was scanned."
_status=0
git ls-files -z --cached --others --exclude-standard >"$_list" ||
_status=$?
[ "$_status" -eq 0 ] || {
rm -f "$_list"
fail "git ls-files exited $_status, so the working tree was never
enumerated and nothing was established about it."
}
# Repo-relative paths. The scan half cd's to the repo root before it opens
# anything, so they reach it intact and unjoined.
WORKTREE_COUNT="$(tr -dc '\0' <"$_list" | wc -c | tr -d ' ')"
_status=0
scan_listing "$_list" || _status=$?
rm -f "$_list"
return "$_status"
}
check_dist() {
_list="$(mktemp "${TMPDIR:-/tmp}/autistmask-censored-dist.XXXXXX")" ||
fail "could not create a temporary file, so dist/ was not scanned."
_status=0
find "$ROOT/dist" -type f -print0 >"$_list" || _status=$?
[ "$_status" -eq 0 ] || {
rm -f "$_list"
fail "find exited $_status enumerating dist/, so part of the emitted
tree was never walked and an unchecked file there went unchecked. Refusing
to report success."
}
DIST_COUNT="$(tr -dc '\0' <"$_list" | wc -c | tr -d ' ')"
_status=0
scan_listing "$_list" || _status=$?
rm -f "$_list"
return "$_status"
}
usage() {
echo "usage: script/check-censored [--require-dist]" >&2
exit 2
}
main() {
cd "$ROOT"
# Internal re-entry from scan_listing's xargs.
if [ "${1-}" = "$SCAN_FLAG" ]; then
shift
extract_name
make_literals_file
scan_paths "$@"
return $?
fi
require_dist=no
case "${1-}" in
"") ;;
--require-dist) require_dist=yes ;;
*) usage ;;
esac
extract_name
make_literals_file
echo "Checking for censored names..."
tree_status=0
check_worktree || tree_status=$?
dist_status=0
dist_inspected=no
DIST_COUNT=0
if [ -d "$ROOT/dist" ]; then
dist_inspected=yes
check_dist || dist_status=$?
fi
if [ "$tree_status" -ne 0 ] || [ "$dist_status" -ne 0 ]; then
fail "the name appears outside the deliberate exceptions (reported
above). See the header of script/check-censored for what is allowed and
why."
fi
if [ "$dist_inspected" = no ]; then
if [ "$require_dist" = yes ]; then
fail "there is no dist/ to inspect and this run was asked to
require one. Run make build."
fi
cat <<EOF
################################################################################
## WARNING: dist/ WAS NOT INSPECTED BY THIS RUN AND IS NOT PROVEN CLEAN BY IT.
## There is no dist/ in this tree. The working tree is clean, but a build can
## carry text no source file does — a dependency's, or a bundler's. Every
## make build runs this check again with dist/ required, so a release artifact
## is always covered; this run simply had none to look at.
################################################################################
EOF
fi
echo "check-censored: $WORKTREE_COUNT tracked file(s) inspected," \
"$DIST_COUNT file(s) under dist/"
}
main "$@"

View File

@@ -0,0 +1,147 @@
// The transform half of script/vendor-blocklist: upstream's config.json in,
// src/shared/phishingBlocklist.json out. Build-time repo tooling; nothing here
// is shipped to users.
//
// Usage: node script/lib/build-blocklist.js <source.json> <output.json>
//
// What it does, and why each step is here:
//
// - only the blacklist is carried over. The extension matches a hostname and
// its parent domains against that one list; upstream's whitelist, fuzzylist
// and version metadata are read by nothing here, so shipping them would add
// megabytes of dead weight to every install.
// - entries are lowercased and de-duplicated, because that is the form
// isPhishingDomain() compares against.
// - entries that cannot be a hostname are dropped and counted. Upstream
// carries the odd URL-shaped entry (a path, a scheme); hostname matching can
// never match one, and once the artifact is hashes nobody can see that it is
// in there, so it is reported at vendoring time instead.
// - entries are hashed (see src/shared/domainHash.js) and sorted, and the
// digests are concatenated into one fixed-width string. Sorted is what makes
// the runtime lookup a binary search over that string, with no set to build
// on every service-worker wake; one string rather than an array of 100k+ is
// what keeps the file, the bundle and the JSON parse small.
//
// Deterministic by construction: same input bytes, same output bytes.
"use strict";
const fs = require("fs");
const {
HASH_ALGORITHM,
HASH_HEX_CHARS,
hashDomain,
} = require("../../src/shared/domainHash");
// A blocklist that has collapsed to a handful of entries is a broken fetch or a
// changed upstream shape, not a quiet day in phishing. Vendoring it would
// disarm the feature, so it fails instead and a human decides.
const MIN_ENTRIES = 10000;
function fail(message) {
process.stderr.write("build-blocklist: " + message + "\n");
process.exit(1);
}
// A hostname, as the matcher understands one: dot-separated labels of letters,
// digits, hyphens and underscores. Anything else — a path, a scheme, a space,
// an empty string, a non-ASCII label a browser would have punycoded before it
// ever reached isPhishingDomain() — cannot be produced by the hostname variants
// the extension looks up, so it could only ever sit in the artifact unused.
//
// Underscores are deliberate. They are not legal in a hostname per RFC 1123,
// but DNS carries them and browsers resolve them, and upstream lists 141 entries
// that use one — real phishing sites on shared subdomain hosts. A stricter
// pattern silently drops every one of them.
const HOSTNAME_RE =
/^[a-z0-9_]([a-z0-9_-]*[a-z0-9_])?(\.[a-z0-9_]([a-z0-9_-]*[a-z0-9_])?)+$/;
function main(argv) {
const [source, output] = argv;
if (!source || !output) {
fail("usage: build-blocklist.js <source.json> <output.json>");
}
let config;
try {
config = JSON.parse(fs.readFileSync(source, "utf8"));
} catch (e) {
fail("could not read " + source + " as JSON: " + e.message);
}
if (!Array.isArray(config.blacklist)) {
fail(
"the source has no blacklist array, so its shape is not the one " +
"this transform understands. Refusing to write an artifact.",
);
}
const seen = new Set();
let dropped = 0;
for (const raw of config.blacklist) {
if (typeof raw !== "string") {
dropped++;
continue;
}
const domain = raw.trim().toLowerCase();
if (!HOSTNAME_RE.test(domain)) {
dropped++;
continue;
}
seen.add(domain);
}
if (seen.size < MIN_ENTRIES) {
fail(
"the source yielded " +
seen.size +
" usable entries, below the " +
MIN_ENTRIES +
" floor. That is a broken source or a changed upstream " +
"shape, and vendoring it would disarm phishing detection. " +
"Refusing to write an artifact.",
);
}
const hashes = [];
for (const domain of seen) hashes.push(hashDomain(domain));
hashes.sort();
// Truncation makes collisions possible; they are harmless (both entries are
// blocked either way) but they must not inflate the count the artifact
// claims, which the runtime cross-checks against the string length.
const unique = [];
for (const hash of hashes) {
if (unique.length === 0 || unique[unique.length - 1] !== hash) {
unique.push(hash);
}
}
const artifact = {
algorithm: HASH_ALGORITHM,
hashHexChars: HASH_HEX_CHARS,
count: unique.length,
hashes: unique.join(""),
};
// Four-space JSON with a trailing newline: what prettier emits for this
// shape, so a vendored artifact passes make fmt-check untouched.
fs.writeFileSync(output, JSON.stringify(artifact, null, 4) + "\n");
process.stdout.write(
"build-blocklist: " +
config.blacklist.length +
" source entries -> " +
seen.size +
" usable domains -> " +
unique.length +
" digests (" +
dropped +
" not hostnames, " +
(seen.size - unique.length) +
" digest collisions)\n",
);
}
main(process.argv.slice(2));

View File

@@ -1,13 +1,51 @@
#!/bin/sh #!/bin/sh
# script/lint: run the linter. # script/lint: run the linter (eslint, then prettier --check).
#
# Linting is containerized. ESLint results depend on the ESLint version, and
# the pinned one is the one in the image; a host's own install must not be
# able to decide whether this repo is green. From a host this therefore builds
# the Dockerfile's `lint` stage, which runs this same script inside the image.
#
# AUTISTMASK_LINT_NATIVE is set only in that image (see the Dockerfile) and is
# what stops the recursion, so `make check` inside the CI build lints in place
# instead of trying to reach a docker daemon it does not have.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
case "${AUTISTMASK_LINT_NATIVE:-}" in
1)
echo "Linting..." echo "Linting..."
yarn run lint 2>&1 yarn run lint 2>&1
return 0
;;
"") ;;
*)
# Set but not recognized: say so rather than silently taking the
# docker path, which would look like the variable had no effect.
echo "lint: AUTISTMASK_LINT_NATIVE is set to" \
"'${AUTISTMASK_LINT_NATIVE}'; the only recognized value is 1" >&2
exit 1
;;
esac
if ! command -v docker >/dev/null 2>&1; then
echo "lint: docker is required; linting does not run on the host" >&2
exit 1
fi
echo "Linting in the pinned container..."
# --progress=plain: the default progress renderer collapses the lint
# output on success, and a lint run whose output cannot be seen is not
# evidence that it ran.
#
# --output=type=cacheonly: the exit status is the whole result; exporting
# an image afterwards costs about ten times the lint itself.
docker build --progress=plain --target lint \
--output=type=cacheonly . 2>&1
} }
main "$@" main "$@"

View File

@@ -1,19 +1,49 @@
#!/bin/sh #!/bin/sh
# script/test: run the test suite. # script/test: run the test suite.
#
# The timeout bounds a hung suite; it is not a performance budget. On a
# developer host the suite finishes in about 8s and REPO_POLICIES' 30s cap is
# the bound. Inside the image the same suite also pays a cold jest cache and
# shares the runner with the rest of the build, which is not what that budget
# describes, so the Dockerfile raises the bound through
# AUTISTMASK_TEST_TIMEOUT. A cap a healthy suite can trip on a cold cache
# produces a red that means nothing, and teaches "just run it again".
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
TIMEOUT="${AUTISTMASK_TEST_TIMEOUT:-30}"
main() { main() {
cd "$ROOT" cd "$ROOT"
echo "Running tests..." echo "Running tests (timeout ${TIMEOUT}s)..."
timeout 30 yarn run test 2>&1 || {
status=0
timeout "$TIMEOUT" yarn run test 2>&1 || status=$?
[ "$status" -eq 0 ] && return 0
# 124 is timeout(1) killing the suite. Say so: a kill is not a failed
# assertion, and the verbose rerun would only spend the same wall clock
# to be killed again.
if [ "$status" -eq 124 ]; then
echo "tests: TIMED OUT after ${TIMEOUT}s (no assertion failed)" >&2
echo "tests: raise AUTISTMASK_TEST_TIMEOUT if the suite is healthy" >&2
exit 1
fi
# 125 is timeout(1) itself failing, which here means AUTISTMASK_TEST_TIMEOUT
# is not a duration it accepts. The suite never ran, so it neither timed out
# nor failed, and the verbose rerun would only reprint the same complaint.
if [ "$status" -eq 125 ]; then
echo "tests: DID NOT RUN: timeout(1) rejected AUTISTMASK_TEST_TIMEOUT=\"${TIMEOUT}\"" >&2
echo "tests: set it to a duration such as 30 or 180 (see timeout(1))" >&2
exit 1
fi
echo "--- Rerunning with --verbose for details ---" echo "--- Rerunning with --verbose for details ---"
timeout 30 yarn run test:verbose 2>&1 || true timeout "$TIMEOUT" yarn run test:verbose 2>&1 || true
# Always fail: the first run already proved the tests are broken, so a # Always fail: the first run already proved the tests are broken, so a
# flaky pass on the rerun must not turn the build green. # flaky pass on the rerun must not turn the build green.
exit 1 exit 1
}
} }
main "$@" main "$@"

View File

@@ -5,19 +5,32 @@
# #
# Deliberately NOT called by script/check or script/test: REPO_POLICIES.md # 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 # 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 # yourself before touching popup views. ESLint's no-undef now catches a
# a used-but-not-imported identifier blow up at runtime. # used-but-not-imported identifier in make check, but only this suite sees
# what a view actually does when it runs.
# .gitea/workflows/e2e.yml also runs it on every push, in a job separate
# from check so that cap and the local fast path both stay intact.
#
# Docker is the only prerequisite. The repo reaches the container as a
# build context and the extension is built inside it (see
# tests/e2e/Dockerfile), so nothing here depends on the node, yarn or make
# on the machine that starts the run. That is not a convenience: a bind
# mount cannot work under Gitea Actions, and the runner image's node is too
# old to install this repo's dependencies.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
# mcr.microsoft.com/playwright:v1.56.0-noble, 2026-08-09 IMAGE="$("$SCRIPT_DIR/projectname")-e2e-chrome"
#
# The playwright-core devDependency is pinned to the matching Playwright IIDFILE=""
# 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 cleanup() {
# revision its own version expects. A mismatch fails at launch. if [ -n "$IIDFILE" ]; then
IMAGE="mcr.microsoft.com/playwright@sha256:35246d87a7c88ea9b771c65d33171b2611b02a8253b4b12ce6f94376c55f99f2" rm -f "$IIDFILE"
fi
}
main() { main() {
cd "$ROOT" cd "$ROOT"
@@ -27,22 +40,31 @@ main() {
exit 1 exit 1
fi fi
echo "Building extension for e2e..." IIDFILE="$(mktemp)"
yarn run build 2>&1 trap cleanup EXIT
trap 'cleanup; exit 130' INT TERM
echo "Building the Chrome e2e image (extension included)..."
docker build --iidfile "$IIDFILE" -t "$IMAGE" -f tests/e2e/Dockerfile .
echo "Running e2e suite in the pinned Playwright container..." echo "Running e2e suite in the pinned Playwright container..."
# The image is run by ID, not by tag: where two clones of this repo run
# the suite at once, the other build can move the tag between this
# build and this run, and the suite would then silently test the other
# checkout.
#
# --ipc=host: Chromium's shared-memory needs more than the default # --ipc=host: Chromium's shared-memory needs more than the default
# 64MB /dev/shm or renderers crash. # 64MB /dev/shm or renderers crash.
# --user: keep files the suite touches owned by the caller, not root. # HOME=/tmp: the image's root home is not a reliable place for the
# HOME=/tmp: the mapped uid has no home directory in the image. # browser profile.
# PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1: without it, # PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1: without it,
# ctx.route() intercepts page requests only, and every fetch made by # ctx.route() intercepts page requests only, and every fetch made by
# the MV3 background service worker — including the phishing # the MV3 background service worker — the JSON-RPC calls behind
# blocklist fetch that src/background/index.js issues at worker # every approval the suite drives among them — goes to the real
# startup — goes to the real internet. The flag is experimental and # internet. The flag is experimental and Playwright may drop or
# Playwright may drop or rename it. It cannot break silently: the # rename it. It cannot break silently: the harness asks the worker
# harness probes service-worker interception at launch and aborts # for one request of its own at launch and aborts the whole suite
# the whole suite if it is not in effect (see the interception # if it does not reach the route handler (see the interception
# canary in tests/e2e/harness.js). If a future Playwright removes # 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 # the flag, that probe is what will fail, and the fix is either a
# replacement mechanism or an honest downgrade of the isolation # replacement mechanism or an honest downgrade of the isolation
@@ -51,13 +73,10 @@ main() {
# on a deliberate bump. # on a deliberate bump.
docker run --rm \ docker run --rm \
--ipc=host \ --ipc=host \
--user "$(id -u):$(id -g)" \
-e HOME=/tmp \ -e HOME=/tmp \
-e PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1 \ -e PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1 \
-e "E2E_TRACE_NETWORK=${E2E_TRACE_NETWORK:-0}" \ -e "E2E_TRACE_NETWORK=${E2E_TRACE_NETWORK:-0}" \
-v "$ROOT:/work" \ "$(cat "$IIDFILE")" \
-w /work \
"$IMAGE" \
node tests/e2e/run.js node tests/e2e/run.js
} }

View File

@@ -5,12 +5,17 @@
# #
# Deliberately NOT called by script/check or script/test, for the same # Deliberately NOT called by script/check or script/test, for the same
# reason as the Chrome suite: REPO_POLICIES.md caps make test at 20 seconds # reason as the Chrome suite: REPO_POLICIES.md caps make test at 20 seconds
# and a browser suite does not fit. # and a browser suite does not fit. .gitea/workflows/e2e.yml also runs it
# on every push, in a job separate from check.
# #
# Unlike script/test-e2e this builds its image locally, because no # Unlike script/test-e2e this builds its base image locally, because no
# published image carries both a pinned Firefox and a matching geckodriver. # published image carries both a pinned Firefox and a matching geckodriver.
# All three external artifacts are pinned by digest inside the Dockerfile; # All three external artifacts are pinned by digest inside the Dockerfile;
# see tests/e2e/firefox/Dockerfile. # see tests/e2e/firefox/Dockerfile, which also explains why the repo and
# the extension build are baked into the image rather than mounted.
#
# Docker is the only prerequisite: nothing here depends on the node, yarn
# or make on the machine that starts the run.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -18,6 +23,14 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
IMAGE="$("$SCRIPT_DIR/projectname")-e2e-firefox" IMAGE="$("$SCRIPT_DIR/projectname")-e2e-firefox"
IIDFILE=""
cleanup() {
if [ -n "$IIDFILE" ]; then
rm -f "$IIDFILE"
fi
}
main() { main() {
cd "$ROOT" cd "$ROOT"
@@ -26,16 +39,20 @@ main() {
exit 1 exit 1
fi fi
echo "Building extension for e2e..." IIDFILE="$(mktemp)"
yarn run build 2>&1 trap cleanup EXIT
trap 'cleanup; exit 130' INT TERM
# The build context is tests/e2e/firefox/ and holds nothing but the echo "Building the pinned Firefox e2e image (extension included)..."
# Dockerfile: the harness itself arrives over the bind mount below, so docker build --iidfile "$IIDFILE" -t "$IMAGE" \
# editing it never invalidates an image layer. -f tests/e2e/firefox/Dockerfile .
echo "Building the pinned Firefox e2e image..."
docker build -t "$IMAGE" "$ROOT/tests/e2e/firefox"
echo "Running the Firefox e2e suite..." echo "Running the Firefox e2e suite..."
# The image is run by ID, not by tag: where two clones of this repo run
# the suite at once, the other build can move the tag between this
# build and this run, and the suite would then silently test the other
# checkout.
#
# --shm-size=1g: Firefox needs more than the default 64MB /dev/shm. # --shm-size=1g: Firefox needs more than the default 64MB /dev/shm.
# --network none: the suite stubs nothing, so this is what keeps the # --network none: the suite stubs nothing, so this is what keeps the
# run offline and deterministic. The extension swallows its own # run offline and deterministic. The extension swallows its own
@@ -43,8 +60,8 @@ main() {
# network note in README.md. Weaker than the Chrome suite's # network note in README.md. Weaker than the Chrome suite's
# fixture interception, and honestly so — it proves no request # fixture interception, and honestly so — it proves no request
# escaped, but it cannot report which ones were attempted. # escaped, but it cannot report which ones were attempted.
# --user: keep files the suite touches owned by the caller, not root. # HOME=/tmp: the image's root home is not a reliable place for the
# HOME=/tmp: the mapped uid has no home directory in the image. # browser profile.
# #
# No --privileged. Firefox's sandbox logs # No --privileged. Firefox's sandbox logs
# "CanCreateUserNamespace() clone() failure: EPERM" on startup here; # "CanCreateUserNamespace() clone() failure: EPERM" on startup here;
@@ -52,11 +69,8 @@ main() {
docker run --rm \ docker run --rm \
--shm-size=1g \ --shm-size=1g \
--network none \ --network none \
--user "$(id -u):$(id -g)" \
-e HOME=/tmp \ -e HOME=/tmp \
-v "$ROOT:/work" \ "$(cat "$IIDFILE")" \
-w /work \
"$IMAGE" \
node tests/e2e/firefox/run.js dist/firefox node tests/e2e/firefox/run.js dist/firefox
} }

105
script/vendor-blocklist Executable file
View File

@@ -0,0 +1,105 @@
#!/bin/sh
# script/vendor-blocklist: refresh the vendored phishing blocklist at
# src/shared/phishingBlocklist.json from its upstream source. Our own extension
# to scripts-to-rule-them-all.
#
# This is build-time repo tooling and is not shipped. It is the one place in
# this repo that names the upstream project, because a source reference that
# does not say what the source is cannot be verified by anyone; the artifact it
# writes carries no names at all (see src/shared/domainHash.js).
# script/check-censored reads the name back out of this file rather than
# repeating it, so it stays defined exactly once.
#
# Run it deliberately, not on every build: the output is committed, and the
# extension does no runtime fetching, so the shipped list is exactly as fresh as
# the last time someone ran this and landed the result. Re-run it, land the
# diff, cut a release; that is the whole refresh path.
#
# Pinned by content hash, twice over, as REPO_POLICIES.md requires. The commit
# below is an immutable ref — the upstream default branch moves several times a
# day and cannot be pinned — and UPSTREAM_SHA256 is the sha256 of the bytes that
# commit serves. A mismatch is a hard failure: a vendoring step that accepts
# whatever it is handed is a supply-chain hole, and this one feeds a security
# warning shown to users.
#
# To move the pin: pick the new commit, run this with the new UPSTREAM_COMMIT
# and an UPSTREAM_SHA256 you have not yet updated, and it will print the hash it
# actually got. Verify that hash against the source independently before
# recording it. Never copy the "actual" line in on trust.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Upstream, pinned 2026-08-17.
UPSTREAM_ORG="MetaMask"
UPSTREAM_REPO="eth-phishing-detect"
UPSTREAM_COMMIT="6dddf74a87da3e1a0841f7ae0d1cb31aaf2c05db"
UPSTREAM_FILE="src/config.json"
UPSTREAM_SHA256="166d5b3504e8f4ed52eae37d3dd20c1a56efa0502bfb3dc957044ff8b5f1283f"
OUTPUT="src/shared/phishingBlocklist.json"
WORK=""
cleanup() {
[ -z "$WORK" ] || rm -rf "$WORK"
}
trap cleanup EXIT INT TERM
fail() {
echo "vendor-blocklist: $*" >&2
exit 1
}
sha256_of() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | cut -d' ' -f1
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" | cut -d' ' -f1
else
fail "neither sha256sum nor shasum is available, so the fetched
source cannot be verified. Refusing to vendor unverified content."
fi
}
main() {
cd "$ROOT"
command -v curl >/dev/null 2>&1 ||
fail "curl is required to fetch the upstream list"
command -v node >/dev/null 2>&1 ||
fail "node is required to build the artifact; run script/bootstrap"
WORK="$(mktemp -d "${TMPDIR:-/tmp}/autistmask-vendor-blocklist.XXXXXX")" ||
fail "could not create a working directory"
url="https://raw.githubusercontent.com/$UPSTREAM_ORG/$UPSTREAM_REPO/$UPSTREAM_COMMIT/$UPSTREAM_FILE"
echo "Fetching $url"
curl -fsSL --proto '=https' --tlsv1.2 -o "$WORK/source.json" "$url" ||
fail "the fetch failed, so nothing was vendored"
actual="$(sha256_of "$WORK/source.json")"
if [ "$actual" != "$UPSTREAM_SHA256" ]; then
fail "sha256 mismatch on the fetched source.
expected: $UPSTREAM_SHA256
actual: $actual
The pinned commit is immutable, so the same commit serving different bytes
means the content was substituted somewhere between upstream and here.
Nothing was written. Do not update the expectation to match unless you have
verified the new bytes independently."
fi
echo "Verified sha256 $actual"
node script/lib/build-blocklist.js "$WORK/source.json" "$WORK/out.json" ||
fail "the transform failed, so nothing was written"
if [ -f "$OUTPUT" ] && cmp -s "$WORK/out.json" "$OUTPUT"; then
echo "vendor-blocklist: $OUTPUT is already up to date"
return 0
fi
cp "$WORK/out.json" "$OUTPUT"
echo "vendor-blocklist: wrote $OUTPUT (sha256 $(sha256_of "$OUTPUT"))"
}
main "$@"

View File

@@ -24,32 +24,32 @@ const {
TX_STAGE_VERIFY, TX_STAGE_VERIFY,
TX_STAGE_BROADCAST, TX_STAGE_BROADCAST,
TX_STAGE_INFLIGHT, TX_STAGE_INFLIGHT,
TX_STAGE_NONCE,
} = require("../shared/approvalVerify"); } = require("../shared/approvalVerify");
const { prepareApprovalTx } = require("../shared/approvalTx"); const { prepareApprovalTx } = require("../shared/approvalTx");
const { const { isPhishingDomain } = require("../shared/phishingDomains");
isPhishingDomain,
refreshPhishingListOnSchedule,
initPhishingList,
} = require("../shared/phishingDomains");
const { const {
BALANCE_REFRESH_ALARM, BALANCE_REFRESH_ALARM,
PHISHING_REFRESH_ALARM,
BALANCE_REFRESH_PERIOD_MINUTES, BALANCE_REFRESH_PERIOD_MINUTES,
ensureRecurringAlarms, ensureRecurringAlarms,
registerAlarmHandlers, registerAlarmHandlers,
} = require("../shared/alarms"); } = require("../shared/alarms");
const storageApi = const {
typeof browser !== "undefined" actionApi,
? browser.storage.local runtimeApi,
: chrome.storage.local; storageGet,
const runtime = tabsQuery,
typeof browser !== "undefined" ? browser.runtime : chrome.runtime; tabsSendMessage,
const windowsApi = windowsApi,
typeof browser !== "undefined" ? browser.windows : chrome.windows; windowsCreate,
const tabsApi = typeof browser !== "undefined" ? browser.tabs : chrome.tabs; windowsGetLastFocused,
const actionApi = windowsRemove,
typeof browser !== "undefined" ? browser.browserAction : chrome.action; } = require("../shared/browserApi");
const runtime = runtimeApi();
const windowsNs = windowsApi();
const actionNs = actionApi();
// Connected sites (in-memory, non-persisted): { "origin:address": true } // Connected sites (in-memory, non-persisted): { "origin:address": true }
const connectedSites = {}; const connectedSites = {};
@@ -57,8 +57,126 @@ const connectedSites = {};
// Pending approval requests: { id: { origin, hostname, resolve } } // Pending approval requests: { id: { origin, hostname, resolve } }
const pendingApprovals = {}; const pendingApprovals = {};
// One transaction approval at a time, wallet-wide.
//
// The transaction a site asks for is populated before its approval window
// opens, so that the object the user is shown is the object the signed
// artifact is verified against. Populating fixes the nonce. Two requests
// populated concurrently therefore take the SAME nonce — the node reports the
// same pending count to both, neither having been broadcast — and whichever is
// broadcast second is refused by the network for a nonce it can never be
// re-signed at, because re-signing it would mean signing something other than
// what was displayed.
//
// So the second request is refused while the first is unanswered. It is
// refused before anything is populated, so no second nonce is allocated at
// all, and while the page is still waiting with nothing on screen. The
// alternatives were considered and rejected in
// https://git.eeqj.de/sneak/AutistMask/issues/271: populating again at Confirm
// puts a nonce on screen that is not the nonce that gets signed, and
// allocating around in-flight approvals makes the wallet's own bookkeeping the
// authority on a nonce the network has not accepted, which an abandoned
// approval then leaves a hole in.
//
// Sign approvals are not gated: a signature consumes no nonce.
//
// The slot is null when free, and otherwise the handle of the request holding
// it. Once that request has raised its approval the handle carries the
// approval's id, so that retiring the approval frees the slot: every exit from
// pendingApprovals goes through settleApproval(), which makes that one hook
// complete. The holder's own finally is the backstop for the interval before
// the approval exists.
let txApprovalSlot = null;
// EIP-1474 "resource unavailable": the standard code for a request that is
// refused because another one is already pending.
const TX_APPROVAL_PENDING_CODE = -32002;
// True at every moment this can be sent: the slot is taken immediately before
// the transaction is populated, so the other request is either being prepared
// or on screen. It does not claim the other one is displayed yet, because for
// the length of one network round trip it is not.
const TX_APPROVAL_PENDING_MESSAGE =
"AutistMask handles one transaction at a time, and another one is" +
" already in progress, so this one was not sent. Please finish that" +
" transaction, then send this one again.";
// Take the slot, or refuse. Nothing awaits between the test and the set, so
// two requests that reach this in the same tick cannot both pass it — the
// position of the call in the handler is irrelevant to that, which is why it
// sits after the authorization checks. A page the wallet is going to refuse
// anyway must not be able to take the slot away from the connected site.
function reserveTxApprovalSlot() {
if (txApprovalSlot) return null;
txApprovalSlot = { approvalId: null };
return txApprovalSlot;
}
// Free the slot, if this handle is still the one holding it.
function releaseTxApprovalSlot(handle) {
if (handle && txApprovalSlot !== handle) return;
txApprovalSlot = null;
}
// Free the slot held on behalf of a retired approval. Called from
// settleApproval() for every approval, and a no-op for the ones the slot was
// not taken for.
function releaseTxApprovalSlotFor(approvalId) {
if (txApprovalSlot && txApprovalSlot.approvalId === approvalId) {
txApprovalSlot = null;
}
}
// Nonces this worker has already handed to the node, per chain and address.
// This is the wallet's own knowledge that a nonce is spent, and it is checked
// before a broadcast rather than after: a node's pending count can lag a
// transaction it has itself just accepted, and a request populated inside that
// window would otherwise be signed and sent at a nonce this wallet has already
// used.
//
// The chain is part of the key because nonce spaces are per chain and the
// wallet switches networks. Without it a nonce spent on one chain would refuse
// that nonce on every other chain — and low nonces overlap across chains as a
// matter of course, so the refusal would be both routine and false.
//
// The record dies with the worker, which is correct rather than merely
// convenient: after a restart the node's count is the only answer available,
// and a transaction of this wallet's that the node has forgotten is one the
// user does want to be able to send again.
const broadcastNonces = {};
function broadcastNoncesFor(chainId, address) {
const key =
String(chainId).toLowerCase() +
":" +
String(address || "").toLowerCase();
if (!broadcastNonces[key]) broadcastNonces[key] = new Set();
return broadcastNonces[key];
}
// An approved transaction's nonce as a decimal string, or null if it cannot be
// read as a number. Verification refuses an unreadable nonce before this is
// ever reached; null here only keeps the record from holding junk.
function approvedNonce(approvedTx) {
try {
return BigInt(approvedTx.nonce).toString();
} catch {
return null;
}
}
// What the page is told when a request failed in a way the wallet has no
// specific answer for. -32603 is the JSON-RPC internal error EIP-1474 defines
// and EIP-1193 defers to for RPC-layer failures; no EIP-1193 4xxx code
// describes "the wallet broke", and one is not invented here. The cause is
// logged rather than put in the message: the page gets a stable sentence, the
// background console gets the throw.
const INTERNAL_ERROR_CODE = -32603;
const INTERNAL_ERROR_MESSAGE =
"AutistMask could not complete this request because of an internal error.";
async function getState() { async function getState() {
const result = await storageApi.get("autistmask"); const result = await storageGet("autistmask");
return ( return (
result.autistmask || { result.autistmask || {
wallets: [], wallets: [],
@@ -122,8 +240,8 @@ async function proxyRpc(method, params) {
} }
function resetPopupUrl() { function resetPopupUrl() {
if (actionApi && typeof actionApi.setPopup === "function") { if (actionNs && typeof actionNs.setPopup === "function") {
actionApi.setPopup({ popup: "src/popup/index.html" }); actionNs.setPopup({ popup: "src/popup/index.html" });
} }
} }
@@ -148,11 +266,41 @@ function settleApproval(id, result, options) {
const holdsClaim = !!(options && options.holdsClaim); const holdsClaim = !!(options && options.holdsClaim);
if (approval.attemptInFlight && !holdsClaim) return false; if (approval.attemptInFlight && !holdsClaim) return false;
delete pendingApprovals[id]; delete pendingApprovals[id];
// The transaction-approval slot is held for exactly as long as the
// approval it was taken for is alive, and this is the one place an
// approval stops being alive.
releaseTxApprovalSlotFor(id);
approval.resolve(result); approval.resolve(result);
resetPopupUrl(); resetPopupUrl();
return true; return true;
} }
// What a pending approval resolves to when it is given up on rather than
// answered: the window was closed, or could not be opened at all. A tx or sign
// approval answers the requesting page in EIP-1193 shape; a site-connection
// approval answers the connection handler in its own.
function abandonedResult(approval, code, message) {
if (approval.type === "tx" || approval.type === "sign") {
return { error: { code, message } };
}
return { approved: false, remember: false };
}
// A window the user closed without answering is a refusal by the user, which
// is 4001 and the wording every other rejection path already uses.
const APPROVAL_REJECTED_CODE = 4001;
const APPROVAL_REJECTED_MESSAGE = "User rejected the request.";
// The window could not be opened, so the user was never asked. This is the
// wallet failing, not the user refusing, so it does not claim to be a
// rejection: -32603 is the JSON-RPC code for the wallet's own internal
// failure, and the page is told plainly that nothing was shown.
const APPROVAL_WINDOW_FAILED_CODE = -32603;
const APPROVAL_WINDOW_FAILED_MESSAGE =
"AutistMask could not open its approval window, so this request was not" +
" shown to you and nothing was sent.";
// Take exclusive hold of a pending approval for one attempt, or refuse. // Take exclusive hold of a pending approval for one attempt, or refuse.
// //
// An approval that failed retryably has to stay in pendingApprovals, so its // An approval that failed retryably has to stay in pendingApprovals, so its
@@ -172,19 +320,46 @@ function claimApproval(approval) {
// Release an approval whose attempt failed in a way the user can retry. // Release an approval whose attempt failed in a way the user can retry.
// Nothing was broadcast, so the next attempt may claim it. // Nothing was broadcast, so the next attempt may claim it.
//
// Unless the window it would be retried in is already gone. The user closed it
// while the attempt was running and settleApproval() declined then, correctly,
// because the attempt still owned the approval; the attempt has now failed, so
// nothing owns it and nothing can reach it. Left standing it would hold the
// requesting page's promise open forever and, with it, the transaction
// approval slot. It is settled here as the rejection the closed window
// already meant.
function releaseApproval(approval) { function releaseApproval(approval) {
approval.attemptInFlight = false; approval.attemptInFlight = false;
if (approval.windowClosed) {
settleApproval(
approval.id,
abandonedResult(
approval,
APPROVAL_REJECTED_CODE,
APPROVAL_REJECTED_MESSAGE,
),
);
}
} }
// Open approval in a separate popup window. // Open approval in a separate popup window.
// This is the primary mechanism for tx/sign approvals (triggered programmatically, // This is the primary mechanism for tx/sign approvals (triggered programmatically,
// not from a user gesture) and the fallback for site-connection approvals. // not from a user gesture) and the fallback for site-connection approvals.
function openApprovalWindow(id) { // Never rejects. Its callers raise it from inside a Promise executor and drop
// the result on the floor, so a rejection here would be unhandled.
async function openApprovalWindow(id) {
const popupUrl = runtime.getURL("src/popup/index.html?approval=" + id); const popupUrl = runtime.getURL("src/popup/index.html?approval=" + id);
const popupWidth = 360; const popupWidth = 360;
const popupHeight = 600; const popupHeight = 600;
windowsApi.getLastFocused((currentWin) => { let currentWin = null;
try {
currentWin = await windowsGetLastFocused();
} catch {
// Nothing focused to centre on. The window still opens, at whatever
// position the browser picks.
}
const opts = { const opts = {
url: popupUrl, url: popupUrl,
type: "popup", type: "popup",
@@ -199,12 +374,42 @@ function openApprovalWindow(id) {
currentWin.top + (currentWin.height - popupHeight) / 2, currentWin.top + (currentWin.height - popupHeight) / 2,
); );
} }
windowsApi.create(opts, (win) => {
if (win) { let win = null;
pendingApprovals[id].windowId = win.id; try {
win = await windowsCreate(opts);
} catch (e) {
// The promise namespace reports the failure by rejecting where the
// callback namespace reported it by handing back no window; both land
// on the !win branch below, which settles the approval.
log.errorf("could not open the approval window:", e);
} }
});
}); const approval = pendingApprovals[id];
if (!approval) {
// Settled while the window was opening — an address switch, say.
// Nothing is waiting on it, and a window showing an approval that no
// longer exists is not left on screen. The await above makes this a
// real race: writing the id back would resurrect a bare entry that
// nothing would ever resolve.
if (win) windowsRemove(win.id).catch(() => {});
return;
}
if (!win) {
// No window means no way to ever answer this approval, and an
// approval nothing can answer holds the requesting page's promise
// open forever. Settle it now instead.
settleApproval(
id,
abandonedResult(
approval,
APPROVAL_WINDOW_FAILED_CODE,
APPROVAL_WINDOW_FAILED_MESSAGE,
),
);
return;
}
approval.windowId = win.id;
} }
// Open an approval popup and return a promise that resolves with the user decision. // Open an approval popup and return a promise that resolves with the user decision.
@@ -212,14 +417,14 @@ function openApprovalWindow(id) {
function requestApproval(origin, hostname) { function requestApproval(origin, hostname) {
return new Promise((resolve) => { return new Promise((resolve) => {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
pendingApprovals[id] = { origin, hostname, resolve }; pendingApprovals[id] = { id, origin, hostname, resolve };
if (actionApi && typeof actionApi.openPopup === "function") { if (actionNs && typeof actionNs.openPopup === "function") {
actionApi.setPopup({ actionNs.setPopup({
popup: "src/popup/index.html?approval=" + id, popup: "src/popup/index.html?approval=" + id,
}); });
try { try {
const result = actionApi.openPopup(); const result = actionNs.openPopup();
if (result && typeof result.catch === "function") { if (result && typeof result.catch === "function") {
result.catch(() => openApprovalWindow(id)); result.catch(() => openApprovalWindow(id));
} }
@@ -243,10 +448,13 @@ function requestApproval(origin, hostname) {
// it is pinned here rather than read again at signing time — an address switch // it is pinned here rather than read again at signing time — an address switch
// between approval and signing must refuse, not sign from an account this // between approval and signing must refuse, not sign from an account this
// screen never named. // screen never named.
function requestTxApproval(origin, hostname, approvedTx, approvedFrom) { // `slot` is the transaction-approval slot its caller holds. Handing the
// approval's id to it is what makes retiring the approval free the slot.
function requestTxApproval(origin, hostname, approvedTx, approvedFrom, slot) {
return new Promise((resolve) => { return new Promise((resolve) => {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
pendingApprovals[id] = { pendingApprovals[id] = {
id,
origin, origin,
hostname, hostname,
approvedTx, approvedTx,
@@ -254,6 +462,7 @@ function requestTxApproval(origin, hostname, approvedTx, approvedFrom) {
resolve, resolve,
type: "tx", type: "tx",
}; };
if (slot) slot.approvalId = id;
openApprovalWindow(id); openApprovalWindow(id);
}); });
@@ -267,6 +476,7 @@ function requestSignApproval(origin, hostname, signParams, approvedFrom) {
return new Promise((resolve) => { return new Promise((resolve) => {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
pendingApprovals[id] = { pendingApprovals[id] = {
id,
origin, origin,
hostname, hostname,
signParams, signParams,
@@ -279,13 +489,53 @@ function requestSignApproval(origin, hostname, signParams, approvedFrom) {
}); });
} }
// Detect when an approval popup (browser-action) closes without a response. // Anything only the extension's own pages may say. A content script speaks
// TX and sign approvals now use windows.create() and are handled by the // with the page's URL, so this is what separates the popup from the site the
// windowsApi.onRemoved listener below, but we still handle site-connection // popup is being asked about.
// approval disconnects here. function isExtensionSender(sender) {
const extUrl = runtime.getURL("");
return !!(sender && sender.url && sender.url.startsWith(extUrl));
}
// The approval popup's port: it carries the user's decision on a
// site-connection approval, and its disconnect is how that approval learns the
// popup closed without one.
//
// The decision travels this port rather than a one-off runtime.sendMessage()
// for exactly one reason: the port is also what the popup's window.close()
// disconnects. A message posted on a port is delivered before that port's
// disconnect, so approve-then-close settles as an approval no matter how fast
// the teardown is. Sent as a one-off message the two crossed on independent
// channels with nothing ordering them, and the teardown won every time when
// the prompt was driven in a tab: the user approved and the dApp was told they
// had refused.
//
// TX and sign approvals do not decide here. They stay pending across a
// disconnect — the user can reopen the toolbar popup — and are rejected by the
// windows.onRemoved listener below.
runtime.onConnect.addListener((port) => { runtime.onConnect.addListener((port) => {
if (port.name.startsWith("approval:")) { if (port.name.startsWith("approval:")) {
const id = port.name.split(":")[1]; const id = port.name.split(":")[1];
if (pendingApprovals[id] && isExtensionSender(port.sender)) {
// The extension's own popup is on the other end, so its disconnect
// is a trustworthy "closed" and onRemoved below stands down. The
// sender check is what keeps that from being an off switch: a
// content script that guessed the id and held its port open would
// otherwise disable the only settlement path a prompt whose popup
// never connected has left, and the dApp would wait forever.
pendingApprovals[id].portConnected = true;
}
port.onMessage.addListener((msg) => {
if (!msg || msg.type !== "AUTISTMASK_APPROVAL_DECISION") return;
if (!isExtensionSender(port.sender)) return;
const approval = pendingApprovals[id];
if (!approval || approval.type === "tx" || approval.type === "sign")
return;
settleApproval(id, {
approved: !!msg.approved,
remember: !!msg.remember,
});
});
port.onDisconnect.addListener(() => { port.onDisconnect.addListener(() => {
const approval = pendingApprovals[id]; const approval = pendingApprovals[id];
if (approval) { if (approval) {
@@ -295,7 +545,6 @@ runtime.onConnect.addListener((port) => {
} }
settleApproval(id, { approved: false, remember: false }); settleApproval(id, { approved: false, remember: false });
} }
resetPopupUrl();
}); });
} }
}); });
@@ -585,10 +834,30 @@ async function handleRpc(method, params, origin) {
} }
if (method === "eth_sendTransaction") { if (method === "eth_sendTransaction") {
return await handleSendTransaction(params, origin);
}
// Proxy safe read-only methods to the RPC node
if (PROXY_METHODS.includes(method)) {
try {
const result = await proxyRpc(method, params);
return { result };
} catch (e) {
return { error: { message: e.message } };
}
}
return { error: { message: "Unsupported method: " + method } };
}
// The body of eth_sendTransaction, from the connection check through to the
// user's decision. It takes the single transaction-approval slot once it knows
// it is going to populate a transaction, and holds it until the requesting
// page has its answer.
async function handleSendTransaction(params, origin) {
const s = await getState(); const s = await getState();
const activeAddress = await getActiveAddress(); const activeAddress = await getActiveAddress();
if (!activeAddress) if (!activeAddress) return { error: { message: "No accounts available" } };
return { error: { message: "No accounts available" } };
const hostname = extractHostname(origin); const hostname = extractHostname(origin);
const allowed = s.allowedSites[activeAddress] || []; const allowed = s.allowedSites[activeAddress] || [];
@@ -610,6 +879,23 @@ async function handleRpc(method, params, origin) {
}; };
} }
// Everything above refuses without populating anything, so the slot is
// taken here rather than at the top of the handler: a page the wallet was
// never going to serve must not be able to hold the slot and make the
// connected site's own transaction fail as "already in progress". The
// reservation is atomic because nothing awaits between its test and its
// set, not because of where it sits.
const slot = reserveTxApprovalSlot();
if (!slot) {
return {
error: {
code: TX_APPROVAL_PENDING_CODE,
message: TX_APPROVAL_PENDING_MESSAGE,
},
};
}
try {
// Populate here, before any window opens, so that the transaction the // Populate here, before any window opens, so that the transaction the
// user is shown is a complete one and is the same object the signed // user is shown is a complete one and is the same object the signed
// artifact is checked against. A failure raises no approval at all and // artifact is checked against. A failure raises no approval at all and
@@ -644,43 +930,40 @@ async function handleRpc(method, params, origin) {
hostname, hostname,
approvedTx, approvedTx,
activeAddress, activeAddress,
slot,
); );
if (decision.error) return { error: decision.error }; if (decision.error) return { error: decision.error };
return { result: decision.txHash }; return { result: decision.txHash };
} finally {
// Retiring the approval has normally freed the slot already, through
// settleApproval(); this covers the paths that return before an
// approval exists at all, and frees nothing if another request has
// since taken the slot.
releaseTxApprovalSlot(slot);
} }
// Proxy safe read-only methods to the RPC node
if (PROXY_METHODS.includes(method)) {
try {
const result = await proxyRpc(method, params);
return { result };
} catch (e) {
return { error: { message: e.message } };
}
}
return { error: { message: "Unsupported method: " + method } };
} }
// Broadcast chainChanged to all tabs when the network is switched. // Broadcast chainChanged to all tabs when the network is switched.
function broadcastChainChanged(chainId) { //
tabsApi.query({}, (tabs) => { // Never rejects: its caller is an RPC handler that must answer the page
// whatever the browser made of the broadcast.
async function broadcastChainChanged(chainId) {
let tabs;
try {
tabs = await tabsQuery({});
} catch {
return;
}
for (const tab of tabs) { for (const tab of tabs) {
tabsApi.sendMessage( // A tab with no content script has no receiver, and that is the
tab.id, // ordinary case rather than a fault. The rejection it produces is the
{ // promise-shaped form of the runtime.lastError this used to read.
tabsSendMessage(tab.id, {
type: "AUTISTMASK_EVENT", type: "AUTISTMASK_EVENT",
eventName: "chainChanged", eventName: "chainChanged",
data: chainId, data: chainId,
}, }).catch(() => {});
() => {
if (runtime.lastError) {
// expected for tabs without our content script
} }
},
);
}
});
} }
// Broadcast accountsChanged to all tabs, respecting per-address permissions // Broadcast accountsChanged to all tabs, respecting per-address permissions
@@ -694,29 +977,28 @@ async function broadcastAccountsChanged() {
// being signed and broadcast right now, and neither rejecting it to the // being signed and broadcast right now, and neither rejecting it to the
// page nor closing the window it is reporting into is survivable. // page nor closing the window it is reporting into is survivable.
for (const [id, approval] of Object.entries(pendingApprovals)) { for (const [id, approval] of Object.entries(pendingApprovals)) {
const rejection = const rejection = abandonedResult(
approval.type === "tx" || approval.type === "sign" approval,
? { APPROVAL_REJECTED_CODE,
error: { APPROVAL_REJECTED_MESSAGE,
code: 4001, );
message: "User rejected the request.",
},
}
: { approved: false, remember: false };
if (!settleApproval(id, rejection)) continue; if (!settleApproval(id, rejection)) continue;
if (approval.windowId) { if (approval.windowId) {
windowsApi.remove(approval.windowId, () => { // Rejects when the window has already gone, which is a race the
if (runtime.lastError) { // user wins routinely by closing it themselves.
// window already closed windowsRemove(approval.windowId).catch(() => {});
}
});
} }
} }
resetPopupUrl(); resetPopupUrl();
const s = await getState(); const s = await getState();
const activeAddress = await getActiveAddress(); const activeAddress = await getActiveAddress();
const allowed = activeAddress ? s.allowedSites[activeAddress] || [] : []; const allowed = activeAddress ? s.allowedSites[activeAddress] || [] : [];
tabsApi.query({}, (tabs) => { let tabs;
try {
tabs = await tabsQuery({});
} catch {
return;
}
for (const tab of tabs) { for (const tab of tabs) {
const origin = tab.url ? new URL(tab.url).origin : ""; const origin = tab.url ? new URL(tab.url).origin : "";
const hostname = extractHostname(origin); const hostname = extractHostname(origin);
@@ -724,22 +1006,14 @@ async function broadcastAccountsChanged() {
activeAddress && activeAddress &&
(allowed.includes(hostname) || (allowed.includes(hostname) ||
connectedSites[origin + ":" + activeAddress]); connectedSites[origin + ":" + activeAddress]);
tabsApi.sendMessage( // Same as chainChanged above: a tab without our content script
tab.id, // rejects, and that is expected rather than a fault.
{ tabsSendMessage(tab.id, {
type: "AUTISTMASK_EVENT", type: "AUTISTMASK_EVENT",
eventName: "accountsChanged", eventName: "accountsChanged",
data: hasPermission ? [activeAddress] : [], data: hasPermission ? [activeAddress] : [],
}, }).catch(() => {});
() => {
// Ignore errors for tabs without content script
if (runtime.lastError) {
// expected for tabs without our content script
} }
},
);
}
});
} }
// Background balance refresh: every 60 seconds when the popup isn't open. // Background balance refresh: every 60 seconds when the popup isn't open.
@@ -773,26 +1047,20 @@ async function backgroundRefresh() {
await saveState(); await saveState();
} }
// Both recurring jobs run off alarms, not timers. On Chrome MV3 this file is // The recurring job runs off an alarm, not a timer. On Chrome MV3 this file is
// a service worker that the browser terminates after about 30 seconds idle, // 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 // 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 // module-level state does not outlive it. Alarms are held by the browser and
// wake the worker to deliver them. // wake the worker to deliver them.
registerAlarmHandlers({ registerAlarmHandlers({
[BALANCE_REFRESH_ALARM]: backgroundRefresh, [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,
}); });
// Everything the background context needs re-established on start. This runs // Everything the background context needs re-established on start. This runs
// on a fresh install, on browser startup, and on every revival of a // on a fresh install, on browser startup, and on every revival of a
// terminated worker, so it must be idempotent: ensureRecurringAlarms() only // terminated worker, so it must be idempotent: ensureRecurringAlarms() only
// creates alarms that are missing or carrying a stale period, and // creates alarms that are missing or carrying a stale period, and only clears
// initPhishingList() fetches only when the persisted timestamps say the list // retired ones that are still registered.
// is stale.
// //
// On a fresh install the top-level call and the onInstalled listener both run, // 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. // close enough together that both could see an alarm missing and create it.
@@ -803,10 +1071,7 @@ let backgroundJobsRun = null;
function startBackgroundJobs() { function startBackgroundJobs() {
if (backgroundJobsRun) return backgroundJobsRun; if (backgroundJobsRun) return backgroundJobsRun;
backgroundJobsRun = Promise.all([ backgroundJobsRun = ensureRecurringAlarms()
ensureRecurringAlarms(),
initPhishingList(),
])
.catch((err) => { .catch((err) => {
// An alarm that failed to schedule means a recurring job silently // An alarm that failed to schedule means a recurring job silently
// never runs again; it must not be an unhandled rejection. // never runs again; it must not be an unhandled rejection.
@@ -831,21 +1096,30 @@ startBackgroundJobs();
// verify and broadcast it is waiting on, so a user closing an apparently-hung // verify and broadcast it is waiting on, so a user closing an apparently-hung
// window is an ordinary event with an attempt already in flight behind it. // window is an ordinary event with an attempt already in flight behind it.
// settleApproval() refuses those, which leaves the attempt to report its real // settleApproval() refuses those, which leaves the attempt to report its real
// outcome to the page. // outcome to the page — and the window is recorded as gone, so that an attempt
if (windowsApi && windowsApi.onRemoved) { // which then fails retryably settles instead of waiting in a window that no
windowsApi.onRemoved.addListener((windowId) => { // longer exists.
//
// A site-connection approval whose popup connected its port is not decided
// here. That popup approves and closes in the same breath, and this event
// races the decision on a channel of its own — the same race the port exists
// to end. Its port disconnect says the same thing this event does, in an order
// that is defined, so the disconnect is left to say it. The window closing
// before any port connected is the one case with nothing else to speak for it,
// and is rejected here so the dApp is not left waiting on a window that is
// gone.
if (windowsNs && windowsNs.onRemoved) {
windowsNs.onRemoved.addListener((windowId) => {
for (const [id, approval] of Object.entries(pendingApprovals)) { for (const [id, approval] of Object.entries(pendingApprovals)) {
if (approval.windowId !== windowId) continue; if (approval.windowId !== windowId) continue;
const rejection = const isSite = approval.type !== "tx" && approval.type !== "sign";
approval.type === "tx" || approval.type === "sign" if (isSite && approval.portConnected) continue;
? { const rejection = abandonedResult(
error: { approval,
code: 4001, APPROVAL_REJECTED_CODE,
message: "User rejected the request.", APPROVAL_REJECTED_MESSAGE,
}, );
} if (!settleApproval(id, rejection)) approval.windowClosed = true;
: { approved: false, remember: false };
settleApproval(id, rejection);
} }
}); });
} }
@@ -865,26 +1139,41 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
// keep fallback // keep fallback
} }
} }
handleRpc(msg.method, msg.params, trustedOrigin).then((response) => { handleRpc(msg.method, msg.params, trustedOrigin)
.then((response) => {
sendResponse(response); sendResponse(response);
})
.catch((err) => {
// Without this the page's window.ethereum.request() promise
// stays pending forever: no response is sent, the content
// script posts nothing back, and the dApp cannot tell the
// failure from a slow wallet. handleRpc does real work —
// state loads, provider calls, transaction population — so
// "it does not throw today" is not a property anyone is
// maintaining.
log.errorf("RPC request failed:", msg.method, err);
sendResponse({
error: {
code: INTERNAL_ERROR_CODE,
message: INTERNAL_ERROR_MESSAGE,
},
});
}); });
return true; return true;
} }
// Validate that popup-only messages originate from the extension itself. // Validate that popup-only messages originate from the extension itself.
// The site-connection decision is not here: it is a port message, and it
// is checked the same way where the port is served.
const POPUP_ONLY_TYPES = [ const POPUP_ONLY_TYPES = [
"AUTISTMASK_GET_APPROVAL", "AUTISTMASK_GET_APPROVAL",
"AUTISTMASK_APPROVAL_RESPONSE",
"AUTISTMASK_TX_RESPONSE", "AUTISTMASK_TX_RESPONSE",
"AUTISTMASK_SIGN_RESPONSE", "AUTISTMASK_SIGN_RESPONSE",
]; ];
if (POPUP_ONLY_TYPES.includes(msg.type)) { if (POPUP_ONLY_TYPES.includes(msg.type) && !isExtensionSender(sender)) {
const extUrl = runtime.getURL("");
if (!sender.url || !sender.url.startsWith(extUrl)) {
sendResponse({ error: "Unauthorized sender" }); sendResponse({ error: "Unauthorized sender" });
return false; return false;
} }
}
if (msg.type === "AUTISTMASK_GET_APPROVAL") { if (msg.type === "AUTISTMASK_GET_APPROVAL") {
const approval = pendingApprovals[msg.id]; const approval = pendingApprovals[msg.id];
@@ -915,15 +1204,6 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
return false; return false;
} }
if (msg.type === "AUTISTMASK_APPROVAL_RESPONSE") {
settleApproval(msg.id, {
approved: msg.approved,
remember: msg.remember,
});
resetPopupUrl();
return false;
}
if (msg.type === "AUTISTMASK_TX_RESPONSE") { if (msg.type === "AUTISTMASK_TX_RESPONSE") {
const approval = pendingApprovals[msg.id]; const approval = pendingApprovals[msg.id];
if (!approval) return false; if (!approval) return false;
@@ -959,7 +1239,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
sendResponse({ sendResponse({
error: outcome.error, error: outcome.error,
retryable: outcome.retryable, retryable: outcome.retryable,
stage: TX_STAGE_SIGN, stage: outcome.stage,
}); });
return false; return false;
} }
@@ -974,9 +1254,20 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
return false; return false;
} }
// Which phase the last-resort .catch() below reports. Everything up to
// the broadcastTransaction() call provably never reached the network,
// so an escape from there must not tell the user it might have.
let lastResortStage = TX_STAGE_VERIFY;
(async () => { (async () => {
// The chain this attempt is on, read once. Verification below
// refuses an artifact signed for any other chain, and the nonce
// record is both consulted and written under this one, so a
// network switch part-way through cannot make the check and the
// record disagree about which chain the nonce was spent on.
let chainId;
try { try {
await loadState(); await loadState();
chainId = currentNetwork().chainId;
const activeAddress = await getActiveAddress(); const activeAddress = await getActiveAddress();
// An address switch between approval and signing refuses. The // An address switch between approval and signing refuses. The
// approval named one account; signing from whichever account // approval named one account; signing from whichever account
@@ -999,7 +1290,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
msg.rawSignedTx, msg.rawSignedTx,
approval.approvedTx, approval.approvedTx,
approval.approvedFrom, approval.approvedFrom,
currentNetwork().chainId, chainId,
); );
} catch (e) { } catch (e) {
// A signed transaction that is not the approved one is not // A signed transaction that is not the approved one is not
@@ -1019,14 +1310,40 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
sendResponse({ sendResponse({
error: outcome.error, error: outcome.error,
retryable: outcome.retryable, retryable: outcome.retryable,
stage: TX_STAGE_VERIFY, stage: outcome.stage,
});
return;
}
// A nonce this worker has already broadcast for this address on
// this chain. The node is not asked: it has answered once already,
// and the wallet holding the receipt of that answer is what makes
// this failure one the user can be told did not reach the network.
// A nonce spent on another chain is not spent here — the chains
// count separately, and refusing across them would block ordinary
// use with a message that is not true.
const nonce = approvedNonce(approval.approvedTx);
const spent = broadcastNoncesFor(chainId, approval.approvedFrom);
if (nonce !== null && spent.has(nonce)) {
const outcome = describeTxFailure(TX_STAGE_NONCE, null);
settleApproval(
msg.id,
{ error: { message: outcome.error } },
{ holdsClaim: true },
);
sendResponse({
error: outcome.error,
retryable: outcome.retryable,
stage: outcome.stage,
}); });
return; return;
} }
try { try {
const provider = getProvider(state.rpcUrl); const provider = getProvider(state.rpcUrl);
lastResortStage = TX_STAGE_BROADCAST;
const tx = await provider.broadcastTransaction(msg.rawSignedTx); const tx = await provider.broadcastTransaction(msg.rawSignedTx);
if (nonce !== null) spent.add(nonce);
settleApproval( settleApproval(
msg.id, msg.id,
{ txHash: tx.hash }, { txHash: tx.hash },
@@ -1039,6 +1356,11 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
// tell a transaction that never left from one already in the // tell a transaction that never left from one already in the
// mempool. The page has been given its outcome for this // mempool. The page has been given its outcome for this
// request; a second attempt would report a second one. // request; a second attempt would report a second one.
//
// Unless the node blamed the nonce, which is the one answer
// that says plainly it did not take the transaction:
// describeTxFailure() reclassifies that, and the stage it
// returns is the one reported.
const outcome = describeTxFailure(TX_STAGE_BROADCAST, e); const outcome = describeTxFailure(TX_STAGE_BROADCAST, e);
settleApproval( settleApproval(
msg.id, msg.id,
@@ -1048,10 +1370,31 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
sendResponse({ sendResponse({
error: outcome.error, error: outcome.error,
retryable: outcome.retryable, retryable: outcome.retryable,
stage: TX_STAGE_BROADCAST, stage: outcome.stage,
}); });
} }
})(); })().catch((e) => {
// Every statement above is inside a try, but a throw from one of
// the catch blocks escapes as an unhandled rejection and neither
// the popup nor the page is ever answered. Settle both, through
// the same chokepoint as every other retirement.
log.errorf("transaction approval response failed:", e);
settleApproval(
msg.id,
{
error: {
code: INTERNAL_ERROR_CODE,
message: INTERNAL_ERROR_MESSAGE,
},
},
{ holdsClaim: true },
);
sendResponse({
error: INTERNAL_ERROR_MESSAGE,
retryable: false,
stage: lastResortStage,
});
});
return true; return true;
} }
@@ -1135,7 +1478,25 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
} }
sendResponse({ error: errMsg, retryable }); sendResponse({ error: errMsg, retryable });
} }
})(); })().catch((e) => {
// Same shape as the transaction path: a throw out of the catch
// block above would leave the popup and the page both waiting.
log.errorf("sign approval response failed:", e);
settleApproval(
msg.id,
{
error: {
code: INTERNAL_ERROR_CODE,
message: INTERNAL_ERROR_MESSAGE,
},
},
{ holdsClaim: true },
);
sendResponse({
error: INTERNAL_ERROR_MESSAGE,
retryable: false,
});
});
return true; return true;
} }

View File

@@ -1,12 +1,20 @@
// AutistMask content script — bridges between inpage (window.ethereum) // AutistMask content script — bridges between inpage (window.ethereum)
// and the background service worker via extension messaging. // and the background service worker via extension messaging.
const {
hasBrowserNamespace,
runtimeApi,
sendMessage,
storageGet,
storageSet,
} = require("../shared/browserApi");
// In Chrome (MV3), inpage.js runs as a MAIN-world content script declared // In Chrome (MV3), inpage.js runs as a MAIN-world content script declared
// in the manifest, so no injection is needed here. In Firefox (MV2), the // in the manifest, so no injection is needed here. In Firefox (MV2), the
// "world" key is not supported, so we inject via a <script> tag. // "world" key is not supported, so we inject via a <script> tag.
if (typeof browser !== "undefined") { if (hasBrowserNamespace()) {
const script = document.createElement("script"); const script = document.createElement("script");
script.src = browser.runtime.getURL("src/content/inpage.js"); script.src = runtimeApi().getURL("src/content/inpage.js");
script.onload = function () { script.onload = function () {
this.remove(); this.remove();
}; };
@@ -14,23 +22,27 @@ if (typeof browser !== "undefined") {
} }
// Send the persisted EIP-6963 provider UUID to the inpage script. // Send the persisted EIP-6963 provider UUID to the inpage script.
// Generated once at install time and stored in chrome.storage.local. // Generated once at install time and stored in extension storage.
(function sendProviderUuid() { (async function sendProviderUuid() {
const storage = let uuid = null;
typeof browser !== "undefined" try {
? browser.storage.local const items = await storageGet("eip6963Uuid");
: chrome.storage.local; uuid = items?.eip6963Uuid;
storage.get("eip6963Uuid", (items) => {
let uuid = items?.eip6963Uuid;
if (!uuid) { if (!uuid) {
uuid = crypto.randomUUID(); uuid = crypto.randomUUID();
storage.set({ eip6963Uuid: uuid }); await storageSet({ eip6963Uuid: uuid });
}
} catch {
// Storage was unavailable or refused the write. The announcement
// still has to go out — a provider that never announces is invisible
// to every EIP-6963 dApp — so it goes under a fresh uuid that this
// page load will not outlive.
if (!uuid) uuid = crypto.randomUUID();
} }
window.postMessage( window.postMessage(
{ type: "AUTISTMASK_PROVIDER_UUID", uuid }, { type: "AUTISTMASK_PROVIDER_UUID", uuid },
location.origin, location.origin,
); );
});
})(); })();
// Relay requests from the page to the background script // Relay requests from the page to the background script
@@ -39,27 +51,31 @@ window.addEventListener("message", (event) => {
if (event.data?.type !== "AUTISTMASK_REQUEST") return; if (event.data?.type !== "AUTISTMASK_REQUEST") return;
const { id, method, params } = event.data; const { id, method, params } = event.data;
const runtime = sendMessage({
typeof browser !== "undefined" ? browser.runtime : chrome.runtime; type: "AUTISTMASK_RPC",
id,
runtime.sendMessage( method,
{ type: "AUTISTMASK_RPC", id, method, params, origin: location.origin }, params,
(response) => { origin: location.origin,
})
.then((response) => {
if (response) { if (response) {
window.postMessage( window.postMessage(
{ type: "AUTISTMASK_RESPONSE", id, ...response }, { type: "AUTISTMASK_RESPONSE", id, ...response },
"*", "*",
); );
} }
}, })
); .catch(() => {
// No receiver: the background context is gone. The page's promise
// stays pending, which is what it did before this was a promise
// at all; turning it into a rejection here is a change to what
// dApps see and belongs to its own issue.
});
}); });
// Listen for events pushed from the background (e.g. accountsChanged) // Listen for events pushed from the background (e.g. accountsChanged)
const runtime = runtimeApi().onMessage.addListener((msg) => {
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
runtime.onMessage.addListener((msg) => {
if (msg.type === "AUTISTMASK_EVENT") { if (msg.type === "AUTISTMASK_EVENT") {
window.postMessage( window.postMessage(
{ {

View File

@@ -79,7 +79,7 @@
for (const cb of cbs) { for (const cb of cbs) {
try { try {
cb(data); cb(data);
} catch (e) { } catch {
// ignore listener errors // ignore listener errors
} }
} }
@@ -179,7 +179,8 @@
return this; return this;
}, },
// Some dApps (wagmi) check this to confirm MetaMask-like behavior // Some dApps (wagmi) probe this object to decide whether the provider
// supports the de-facto standard extras. The name is theirs, not ours.
_metamask: { _metamask: {
isUnlocked() { isUnlocked() {
return Promise.resolve(provider.selectedAddress !== null); return Promise.resolve(provider.selectedAddress !== null);

View File

@@ -12,7 +12,6 @@ const {
setBackRenderer, setBackRenderer,
pushCurrentView, pushCurrentView,
goBack, goBack,
clearViewStack,
} = require("./views/helpers"); } = require("./views/helpers");
const { applyTheme } = require("./theme"); const { applyTheme } = require("./theme");
// Renders a view the popup lands on without having navigated to it forward: // Renders a view the popup lands on without having navigated to it forward:
@@ -161,6 +160,12 @@ async function init() {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const approvalId = params.get("approval"); const approvalId = params.get("approval");
if (approvalId) { if (approvalId) {
// Deliberately not awaited, and deliberately not .catch()ed. show()
// is async, so a throw past its first await surfaces as an unhandled
// rejection rather than an uncaught error — measured as still failing
// the run on both harnesses (Playwright `pageerror`, and the Firefox
// driver's console-service drain), so nothing is lost by leaving it
// on that path.
approval.show(approvalId); approval.show(approvalId);
showView("approve-site"); showView("approve-site");
return; return;

View File

@@ -194,7 +194,7 @@ async function importPrivateKey(ctx) {
let addr; let addr;
try { try {
addr = addressFromPrivateKey(key); addr = addressFromPrivateKey(key);
} catch (e) { } catch {
showFlash("Invalid private key."); showFlash("Invalid private key.");
return; return;
} }
@@ -246,7 +246,7 @@ async function importXprvKey(ctx) {
let result; let result;
try { try {
result = hdWalletFromXprv(xprv); result = hdWalletFromXprv(xprv);
} catch (e) { } catch {
showFlash( showFlash(
"That extended private key is not valid. Please check it and try again.", "That extended private key is not valid. Please check it and try again.",
); );

View File

@@ -12,8 +12,8 @@ const {
goBack, goBack,
pushCurrentView, pushCurrentView,
} = require("./helpers"); } = require("./helpers");
const { state, currentAddress, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { formatUsd, getAddressValueUsd } = require("../../shared/prices"); const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
const { const {
fetchRecentTransactions, fetchRecentTransactions,
filterTransactions, filterTransactions,
@@ -44,7 +44,6 @@ function show() {
state.selectedToken = null; state.selectedToken = null;
const wallet = state.wallets[state.selectedWallet]; const wallet = state.wallets[state.selectedWallet];
const addr = wallet.addresses[state.selectedAddress]; const addr = wallet.addresses[state.selectedAddress];
const wi = state.selectedWallet;
const ai = state.selectedAddress; const ai = state.selectedAddress;
$("address-title").textContent = $("address-title").textContent =
wallet.name + " \u2014 Address " + (ai + 1); wallet.name + " \u2014 Address " + (ai + 1);
@@ -64,7 +63,7 @@ function show() {
}); });
$("address-line").dataset.full = addr.address; $("address-line").dataset.full = addr.address;
attachCopyHandlers($("address-line")); attachCopyHandlers($("address-line"));
const usdTotal = formatUsd(getAddressValueUsd(addr)); const usdTotal = formatAddressTotal(getAddressValue(addr));
$("address-usd-total").innerHTML = usdTotal || "&nbsp;"; $("address-usd-total").innerHTML = usdTotal || "&nbsp;";
const ensEl = $("address-ens"); const ensEl = $("address-ens");
// ENS is now shown inside renderAddressHtml, hide the separate element // ENS is now shown inside renderAddressHtml, hide the separate element
@@ -246,7 +245,6 @@ function renderTransactions(txs) {
row.addEventListener("click", () => { row.addEventListener("click", () => {
const idx = parseInt(row.dataset.tx, 10); const idx = parseInt(row.dataset.tx, 10);
const tx = loadedTxs[idx]; const tx = loadedTxs[idx];
const counterparty = tx.direction === "sent" ? tx.to : tx.from;
tx.fromEns = ensNameMap.get(tx.from) || null; tx.fromEns = ensNameMap.get(tx.from) || null;
tx.toEns = ensNameMap.get(tx.to) || null; tx.toEns = ensNameMap.get(tx.to) || null;
ctx.showTransactionDetail(tx); ctx.showTransactionDetail(tx);

View File

@@ -16,13 +16,9 @@ const {
goBack, goBack,
pushCurrentView, pushCurrentView,
} = require("./helpers"); } = require("./helpers");
const { state, currentAddress, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList"); const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
const { const { formatUsd, getPrice } = require("../../shared/prices");
formatUsd,
getPrice,
getAddressValueUsd,
} = require("../../shared/prices");
const { const {
fetchRecentTransactions, fetchRecentTransactions,
filterTransactions, filterTransactions,

View File

@@ -9,7 +9,7 @@ const {
attachCopyHandlers, attachCopyHandlers,
onViewLeave, onViewLeave,
} = require("./helpers"); } = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { networkByChainId } = require("../../shared/networks"); const { networkByChainId } = require("../../shared/networks");
const { const {
formatEther, formatEther,
@@ -27,8 +27,7 @@ const { walletDefect } = require("../../shared/walletDefects");
const { describeSigningFailure } = require("../../shared/approvalVerify"); const { describeSigningFailure } = require("../../shared/approvalVerify");
const txStatus = require("./txStatus"); const txStatus = require("./txStatus");
const uniswap = require("../../shared/uniswap"); const uniswap = require("../../shared/uniswap");
const runtime = const { notify, runtimeApi, sendMessage } = require("../../shared/browserApi");
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
const erc20Iface = new Interface(ERC20_ABI); const erc20Iface = new Interface(ERC20_ABI);
@@ -439,10 +438,20 @@ function showSignApproval(details) {
); );
} }
function show(id) { // Awaited by nobody: the popup entry point calls this and moves on. It
// therefore has to absorb its own failure, and a background that cannot
// describe the approval is the same outcome as an approval that is gone.
async function show(id) {
approvalId = id; approvalId = id;
runtime.connect({ name: "approval:" + id }); approvalPort = runtimeApi().connect({ name: "approval:" + id });
runtime.sendMessage({ type: "AUTISTMASK_GET_APPROVAL", id }, (details) => {
let details = null;
try {
details = await sendMessage({ type: "AUTISTMASK_GET_APPROVAL", id });
} catch {
details = null;
}
if (!details) { if (!details) {
window.close(); window.close();
return; return;
@@ -461,15 +470,20 @@ function show(id) {
details.isPhishingDomain, details.isPhishingDomain,
); );
$("approve-hostname").textContent = details.hostname; $("approve-hostname").textContent = details.hostname;
$("approve-address").innerHTML = approvalAddressHtml( $("approve-address").innerHTML = approvalAddressHtml(state.activeAddress);
state.activeAddress,
);
attachCopyHandlers("view-approve-site"); attachCopyHandlers("view-approve-site");
$("approve-remember").checked = state.rememberSiteChoice; $("approve-remember").checked = state.rememberSiteChoice;
});
} }
let approvalId = null; let approvalId = null;
// The port this approval was opened on. Closing this window disconnects it,
// and the background treats that disconnect as "closed without deciding" for a
// site connection — so the decision goes out on this same port and not as a
// one-off message. One channel is ordered: a message posted on it is delivered
// before its own disconnect, however immediately the close follows. Two
// channels were not, and the close won, reporting a user who approved as
// having refused.
let approvalPort = null;
let pendingTxDetails = null; let pendingTxDetails = null;
// The exact objects shown to the user, kept so the popup signs what it // The exact objects shown to the user, kept so the popup signs what it
// displayed rather than re-fetching or re-populating anything at approval // displayed rather than re-fetching or re-populating anything at approval
@@ -537,7 +551,29 @@ function clearSignPassword() {
hideError("approve-sign-error"); hideError("approve-sign-error");
} }
function init(ctx) { // Answer a site-connection approval and close. The decision goes out on the
// approval port — see approvalPort above for why — and carries no approval id,
// because the port name already names the approval the background will settle.
// The post is guarded because a throw must not cost the close: posting on a
// port whose background worker has been torn down throws, and the approval it
// would have settled died with that worker, so the only thing left to do is
// what the user asked for — go away.
function decideSite(approved) {
if (approvalPort) {
try {
approvalPort.postMessage({
type: "AUTISTMASK_APPROVAL_DECISION",
approved,
remember: $("approve-remember").checked,
});
} catch {
// Nothing to report it to; the window closes either way.
}
}
window.close();
}
function init(_ctx) {
onViewLeave("approve-tx", clearTxPassword); onViewLeave("approve-tx", clearTxPassword);
onViewLeave("approve-sign", clearSignPassword); onViewLeave("approve-sign", clearSignPassword);
@@ -547,25 +583,11 @@ function init(ctx) {
}); });
$("btn-approve").addEventListener("click", () => { $("btn-approve").addEventListener("click", () => {
const remember = $("approve-remember").checked; decideSite(true);
runtime.sendMessage({
type: "AUTISTMASK_APPROVAL_RESPONSE",
id: approvalId,
approved: true,
remember,
});
window.close();
}); });
$("btn-reject").addEventListener("click", () => { $("btn-reject").addEventListener("click", () => {
const remember = $("approve-remember").checked; decideSite(false);
runtime.sendMessage({
type: "AUTISTMASK_APPROVAL_RESPONSE",
id: approvalId,
approved: false,
remember,
});
window.close();
}); });
$("btn-approve-tx").addEventListener("click", async () => { $("btn-approve-tx").addEventListener("click", async () => {
@@ -648,7 +670,16 @@ function init(ctx) {
decryptedSecret = null; decryptedSecret = null;
} }
runtime.sendMessage(payload, (response) => { // A send that never reaches the background is reported to the user
// the same way a background that refused it is: describeSigningFailure
// turns a null response into the generic message below.
let response = null;
try {
response = await sendMessage(payload);
} catch {
response = null;
}
if (response && response.txHash) { if (response && response.txHash) {
txStatus.showWait(pendingTxDetails, response.txHash); txStatus.showWait(pendingTxDetails, response.txHash);
return; return;
@@ -667,10 +698,9 @@ function init(ctx) {
txStatus.showError(pendingTxDetails, null, outcome.message); txStatus.showError(pendingTxDetails, null, outcome.message);
} }
}); });
});
$("btn-reject-tx").addEventListener("click", () => { $("btn-reject-tx").addEventListener("click", () => {
runtime.sendMessage({ notify({
type: "AUTISTMASK_TX_RESPONSE", type: "AUTISTMASK_TX_RESPONSE",
id: approvalId, id: approvalId,
approved: false, approved: false,
@@ -764,7 +794,13 @@ function init(ctx) {
decryptedSecret = null; decryptedSecret = null;
} }
runtime.sendMessage(payload, (response) => { let response = null;
try {
response = await sendMessage(payload);
} catch {
response = null;
}
if (response && response.signature) { if (response && response.signature) {
window.close(); window.close();
return; return;
@@ -780,10 +816,9 @@ function init(ctx) {
showError("approve-sign-error", outcome.message); showError("approve-sign-error", outcome.message);
if (outcome.retryable) setSignButtonBusy(false); if (outcome.retryable) setSignButtonBusy(false);
}); });
});
$("btn-reject-sign").addEventListener("click", () => { $("btn-reject-sign").addEventListener("click", () => {
runtime.sendMessage({ notify({
type: "AUTISTMASK_SIGN_RESPONSE", type: "AUTISTMASK_SIGN_RESPONSE",
id: approvalId, id: approvalId,
approved: false, approved: false,

View File

@@ -2,20 +2,12 @@
// Shows transaction details, warnings, errors. On Sign & Send, // Shows transaction details, warnings, errors. On Sign & Send,
// reads inline password, decrypts secret, signs and broadcasts. // reads inline password, decrypts secret, signs and broadcasts.
const { const { parseEther, parseUnits, formatEther, Contract } = require("ethers");
parseEther,
parseUnits,
formatEther,
formatUnits,
Contract,
} = require("ethers");
const { const {
$, $,
showError, showError,
hideError, hideError,
showView, showView,
showFlash,
flashCopyFeedback,
addressTitle, addressTitle,
escapeHtml, escapeHtml,
renderAddressHtml, renderAddressHtml,
@@ -23,7 +15,7 @@ const {
goBack, goBack,
onViewLeave, onViewLeave,
} = require("./helpers"); } = require("./helpers");
const { state, currentNetwork } = require("../../shared/state"); const { state } = require("../../shared/state");
const { getSignerForAddress } = require("../../shared/wallet"); const { getSignerForAddress } = require("../../shared/wallet");
const { decryptWithPassword } = require("../../shared/vault"); const { decryptWithPassword } = require("../../shared/vault");
const { formatUsd, getPrice } = require("../../shared/prices"); const { formatUsd, getPrice } = require("../../shared/prices");
@@ -399,7 +391,7 @@ function clearPassword() {
hideError("confirm-tx-password-error"); hideError("confirm-tx-password-error");
} }
function init(ctx) { function init(_ctx) {
onViewLeave("confirm-tx", clearPassword); onViewLeave("confirm-tx", clearPassword);
$("btn-confirm-send").addEventListener("click", async () => { $("btn-confirm-send").addEventListener("click", async () => {
@@ -421,7 +413,7 @@ function init(ctx) {
wallet.encryptedSecret, wallet.encryptedSecret,
password, password,
); );
} catch (e) { } catch {
showError( showError(
"confirm-tx-password-error", "confirm-tx-password-error",
"That password is incorrect. Please try again.", "That password is incorrect. Please try again.",

View File

@@ -17,7 +17,7 @@ const {
addressHoldsFunds, addressHoldsFunds,
balanceLinesForAddress, balanceLinesForAddress,
} = require("./helpers"); } = require("./helpers");
const { formatUsd, getAddressValueUsd } = require("../../shared/prices"); const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
const { walletHasRecoveryPhrase } = require("../../shared/wallet"); const { walletHasRecoveryPhrase } = require("../../shared/wallet");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { const {
@@ -84,16 +84,16 @@ function recoveryPathText(wallet) {
// own: the rendered lines round to four decimals, so a sentence built from a // own: the rendered lines round to four decimals, so a sentence built from a
// rounded number would report "0.0000 ETH" for an address holding real money. // rounded number would report "0.0000 ETH" for an address holding real money.
// The lines below it carry the amounts, in the same format as Home and // The lines below it carry the amounts, in the same format as Home and
// AddressDetail, followed by the USD total when prices are known (null on // AddressDetail, followed by the USD total when there is one to give — no
// testnet and before the first price fetch, where the line is left off rather // total line at all on testnet or before the first price fetch, and no figure
// than printed as $0.00). // when every holding here is one with no price, since "$0.00" directly under
// "This address holds a balance." is a contradiction.
function balanceWarningHtml(addr) { function balanceWarningHtml(addr) {
if (!addressHoldsFunds(addr)) return "&nbsp;"; if (!addressHoldsFunds(addr)) return "&nbsp;";
const usd = getAddressValueUsd(addr); const line = formatAddressTotal(getAddressValue(addr));
const total = const total = line
usd === null ? `<div class="text-xs text-muted mt-1">${line}</div>`
? "" : "";
: `<div class="text-xs text-muted mt-1">Total: ${formatUsd(usd)}</div>`;
return ( return (
`<p class="mb-1">This address holds a balance. Removing it does not ` + `<p class="mb-1">This address holds a balance. Removing it does not ` +
`move or spend anything; the balance stays at the address.</p>` + `move or spend anything; the balance stays at the address.</p>` +

View File

@@ -73,7 +73,7 @@ function init(_ctx) {
// Verify password against the wallet's encrypted data // Verify password against the wallet's encrypted data
try { try {
await decryptWithPassword(wallet.encryptedSecret, pw); await decryptWithPassword(wallet.encryptedSecret, pw);
} catch (_e) { } catch {
$("delete-wallet-flash").textContent = $("delete-wallet-flash").textContent =
"That password is incorrect. Please try again."; "That password is incorrect. Please try again.";
$("delete-wallet-flash").style.visibility = "visible"; $("delete-wallet-flash").style.visibility = "visible";

View File

@@ -1,11 +1,7 @@
// Shared DOM helpers used by all views. // Shared DOM helpers used by all views.
const { isDebug } = require("../../shared/log"); const { isDebug } = require("../../shared/log");
const { const { formatUsd, getPrice } = require("../../shared/prices");
formatUsd,
getPrice,
getAddressValueUsd,
} = require("../../shared/prices");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { markViewRendered } = require("../viewRouter"); const { markViewRendered } = require("../viewRouter");

View File

@@ -2,7 +2,6 @@ const {
$, $,
showView, showView,
showFlash, showFlash,
flashCopyFeedback,
balanceLinesForAddress, balanceLinesForAddress,
isoDate, isoDate,
timeAgo, timeAgo,
@@ -15,6 +14,7 @@ const {
pushCurrentView, pushCurrentView,
} = require("./helpers"); } = require("./helpers");
const { state, saveState, currentAddress } = require("../../shared/state"); const { state, saveState, currentAddress } = require("../../shared/state");
const { notify } = require("../../shared/browserApi");
const { const {
updateSendBalance, updateSendBalance,
renderSendTokenSelect, renderSendTokenSelect,
@@ -28,8 +28,9 @@ const {
} = require("../../shared/walletDefects"); } = require("../../shared/walletDefects");
const { const {
formatUsd, formatUsd,
formatAddressTotal,
getPrice, getPrice,
getAddressValueUsd, getAddressValue,
} = require("../../shared/prices"); } = require("../../shared/prices");
const { const {
fetchRecentTransactions, fetchRecentTransactions,
@@ -71,9 +72,7 @@ function renderTotalValue() {
el.textContent = ethStr + ethUsd; el.textContent = ethStr + ethUsd;
if (subEl) { if (subEl) {
const totalUsd = getAddressValueUsd(addr); subEl.innerHTML = formatAddressTotal(getAddressValue(addr)) || "&nbsp;";
subEl.innerHTML =
totalUsd !== null ? "Total: " + formatUsd(totalUsd) : "&nbsp;";
} }
} }
@@ -257,8 +256,8 @@ function walletListHtml() {
html += `<span class="flex items-center break-all">${addr.ensName ? "" : dot}${addr.address}</span>`; html += `<span class="flex items-center break-all">${addr.ensName ? "" : dot}${addr.address}</span>`;
html += `<span class="flex-shrink-0 ml-1">${infoBtn}${removeBtn}</span>`; html += `<span class="flex-shrink-0 ml-1">${infoBtn}${removeBtn}</span>`;
html += `</div>`; html += `</div>`;
const addrUsd = formatUsd(getAddressValueUsd(addr)); const addrTotal = formatAddressTotal(getAddressValue(addr));
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrUsd || "&nbsp;"}</div>`; html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrTotal || "&nbsp;"}</div>`;
html += balanceLinesForAddress( html += balanceLinesForAddress(
addr, addr,
state.trackedTokens, state.trackedTokens,
@@ -293,11 +292,7 @@ function render(ctx) {
state.activeAddress = addr; state.activeAddress = addr;
await saveState(); await saveState();
render(ctx); render(ctx);
const runtime = notify({ type: "AUTISTMASK_ACTIVE_CHANGED" });
typeof browser !== "undefined"
? browser.runtime
: chrome.runtime;
runtime.sendMessage({ type: "AUTISTMASK_ACTIVE_CHANGED" });
} }
}); });
}); });

View File

@@ -57,7 +57,7 @@ function show() {
attachCopyHandlers("view-receive"); attachCopyHandlers("view-receive");
} }
function init(ctx) { function init(_ctx) {
$("btn-receive-copy").addEventListener("click", () => { $("btn-receive-copy").addEventListener("click", () => {
const addr = $("receive-address-block").dataset.full; const addr = $("receive-address-block").dataset.full;
if (addr) { if (addr) {

View File

@@ -4,7 +4,6 @@ const {
$, $,
showFlash, showFlash,
addressTitle, addressTitle,
escapeHtml,
renderAddressHtml, renderAddressHtml,
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
@@ -210,7 +209,7 @@ function init(_ctx) {
} }
resolvedTo = resolved; resolvedTo = resolved;
ensName = to; ensName = to;
} catch (e) { } catch {
showFlash("Failed to resolve ENS name."); showFlash("Failed to resolve ENS name.");
return; return;
} }

View File

@@ -14,7 +14,6 @@ const {
parseDustThresholdGwei, parseDustThresholdGwei,
} = require("../dustThreshold"); } = require("../dustThreshold");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { NETWORKS, SUPPORTED_CHAIN_IDS } = require("../../shared/networks");
const { onChainSwitch } = require("../../shared/chainSwitch"); const { onChainSwitch } = require("../../shared/chainSwitch");
const { log, debugFetch, setRuntimeDebug } = require("../../shared/log"); const { log, debugFetch, setRuntimeDebug } = require("../../shared/log");
const deleteWallet = require("./deleteWallet"); const deleteWallet = require("./deleteWallet");
@@ -29,8 +28,7 @@ const {
GITEA_COMMIT_URL, GITEA_COMMIT_URL,
} = require("../../shared/buildInfo"); } = require("../../shared/buildInfo");
const runtime = const { notify } = require("../../shared/browserApi");
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
let versionClickCount = 0; let versionClickCount = 0;
let versionClickTimer = null; let versionClickTimer = null;
@@ -61,7 +59,7 @@ function renderSiteList(containerId, siteMap, stateKey) {
} }
} }
await saveState(); await saveState();
runtime.sendMessage({ type: "AUTISTMASK_REMOVE_SITE" }); notify({ type: "AUTISTMASK_REMOVE_SITE" });
renderSiteList(containerId, state[key], key); renderSiteList(containerId, state[key], key);
}); });
}); });
@@ -169,10 +167,7 @@ function renderWalletListSettings() {
function show() { function show() {
$("settings-rpc").value = state.rpcUrl; $("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl; $("settings-blockscout").value = state.blockscoutUrl;
const networkSelect = $("settings-network"); $("settings-network").value = state.networkId;
if (networkSelect) {
networkSelect.value = state.networkId;
}
renderTrackedTokens(); renderTrackedTokens();
renderSiteLists(); renderSiteLists();
renderWalletListSettings(); renderWalletListSettings();
@@ -284,7 +279,6 @@ function init(ctx) {
}); });
const networkSelect = $("settings-network"); const networkSelect = $("settings-network");
if (networkSelect) {
networkSelect.addEventListener("change", async () => { networkSelect.addEventListener("change", async () => {
const newId = networkSelect.value; const newId = networkSelect.value;
const net = await onChainSwitch(newId); const net = await onChainSwitch(newId);
@@ -292,7 +286,6 @@ function init(ctx) {
$("settings-blockscout").value = state.blockscoutUrl; $("settings-blockscout").value = state.blockscoutUrl;
showFlash("Switched to " + net.name + "."); showFlash("Switched to " + net.name + ".");
}); });
}
$("settings-show-zero-balances").checked = state.showZeroBalanceTokens; $("settings-show-zero-balances").checked = state.showZeroBalanceTokens;
$("settings-show-zero-balances").addEventListener("change", async () => { $("settings-show-zero-balances").addEventListener("change", async () => {

View File

@@ -23,8 +23,6 @@ const makeBlockie = require("ethereum-blockies-base64");
const { log, debugFetch } = require("../../shared/log"); const { log, debugFetch } = require("../../shared/log");
const { decodeCalldata } = require("./approval"); const { decodeCalldata } = require("./approval");
let ctx;
/** /**
* Determine a human-readable transaction type string from tx fields. * Determine a human-readable transaction type string from tx fields.
*/ */
@@ -166,7 +164,7 @@ function render() {
if (el) el.classList.add("hidden"); if (el) el.classList.add("hidden");
} }
loadFullTxDetails(tx.hash, tx.to, tx.isContractCall); loadFullTxDetails(tx.hash, tx.to);
const isoStr = isoDate(tx.timestamp); const isoStr = isoDate(tx.timestamp);
$("tx-detail-time").innerHTML = $("tx-detail-time").innerHTML =
@@ -274,7 +272,7 @@ function populateOnChainDetails(txData) {
} }
} }
async function loadFullTxDetails(txHash, toAddress, isContractCall) { async function loadFullTxDetails(txHash, toAddress) {
const section = $("tx-detail-calldata-section"); const section = $("tx-detail-calldata-section");
const actionEl = $("tx-detail-calldata-action"); const actionEl = $("tx-detail-calldata-action");
const detailsEl = $("tx-detail-calldata-details"); const detailsEl = $("tx-detail-calldata-details");
@@ -349,8 +347,9 @@ async function loadFullTxDetails(txHash, toAddress, isContractCall) {
} }
} }
// The ctx this view is initialized with is unused: this module is the leaf of
// the navigation, and the other views reach it through their own ctx.
function init(_ctx) { function init(_ctx) {
ctx = _ctx;
$("btn-tx-back").addEventListener("click", () => { $("btn-tx-back").addEventListener("click", () => {
goBack(); goBack();
}); });

View File

@@ -12,7 +12,7 @@ const {
clearViewStack, clearViewStack,
} = require("./helpers"); } = require("./helpers");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList"); const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, currentNetwork } = require("../../shared/state");
const { getProvider } = require("../../shared/balances"); const { getProvider } = require("../../shared/balances");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
@@ -229,10 +229,6 @@ function tokenLabel(address) {
return t ? t.symbol : null; return t ? t.symbol : null;
} }
function etherscanTokenLink(address) {
return `${currentNetwork().explorerUrl}/token/${address}`;
}
function decodedDetailsHtml(decoded) { function decodedDetailsHtml(decoded) {
if (!decoded || !decoded.details) return ""; if (!decoded || !decoded.details) return "";
let html = `<div class="border border-border border-dashed p-2 mb-3">`; let html = `<div class="border border-border border-dashed p-2 mb-3">`;

View File

@@ -17,23 +17,31 @@
// run finished and the alarm fires one run-duration earlier than that. Every // 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 // guard must therefore either be strictly shorter than the period it gates or
// be bypassed on the scheduled tick — see backgroundRefresh() in // be bypassed on the scheduled tick — see backgroundRefresh() in
// src/background/index.js and updatePhishingList() in shared/phishingDomains.js. // src/background/index.js.
const { alarmsApi } = require("./browserApi");
const BALANCE_REFRESH_ALARM = "autistmask-balance-refresh"; const BALANCE_REFRESH_ALARM = "autistmask-balance-refresh";
const PHISHING_REFRESH_ALARM = "autistmask-phishing-refresh";
// Alarms this extension used to create and no longer has a handler for. A
// browser keeps an alarm until something clears it, so a job that is deleted
// from the code goes on waking the service worker on its old schedule forever,
// on every install that ever ran the version which created it. Removing the job
// means removing the alarm, so retired names are listed here and cleared on
// every start until the installs that carry them are long gone.
const OBSOLETE_ALARMS = [
// The 24-hour phishing blocklist refresh, retired when the runtime fetch
// was removed and the list became purely build-time vendored.
"autistmask-phishing-refresh",
];
const MIN_ALARM_PERIOD_MINUTES = 1; const MIN_ALARM_PERIOD_MINUTES = 1;
const BALANCE_REFRESH_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 // alarmsApi() resolves on use rather than at module load: the worker is torn
// down and re-evaluated repeatedly, and tests install a stub after requiring // down and re-evaluated repeatedly, and tests install a stub after requiring
// the module. // this module. It returns null where the API is absent, which is why every
function alarmsApi() { // entry point below degrades instead of throwing.
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. * Create an alarm unless one with the requested period already exists.
@@ -67,22 +75,34 @@ async function ensureAlarm(name, periodInMinutes) {
} }
/** /**
* Ensure both recurring background jobs are scheduled. Safe to call on every * Clear every alarm this extension no longer handles.
* worker start, on onInstalled and on onStartup.
* *
* @returns {Promise<{balance: boolean, phishing: boolean}>} which alarms this * @returns {Promise<string[]>} the retired alarms this call actually cleared.
* call had to create. */
async function clearObsoleteAlarms() {
const api = alarmsApi();
if (!api || !api.clear) return [];
const cleared = [];
for (const name of OBSOLETE_ALARMS) {
if (await api.clear(name)) cleared.push(name);
}
return cleared;
}
/**
* Ensure the recurring background jobs are scheduled, and that retired ones are
* not. Safe to call on every worker start, on onInstalled and on onStartup.
*
* @returns {Promise<{balance: boolean, cleared: string[]}>} which alarms this
* call had to create, and which retired ones it removed.
*/ */
async function ensureRecurringAlarms() { async function ensureRecurringAlarms() {
const balance = await ensureAlarm( const balance = await ensureAlarm(
BALANCE_REFRESH_ALARM, BALANCE_REFRESH_ALARM,
BALANCE_REFRESH_PERIOD_MINUTES, BALANCE_REFRESH_PERIOD_MINUTES,
); );
const phishing = await ensureAlarm( const cleared = await clearObsoleteAlarms();
PHISHING_REFRESH_ALARM, return { balance, cleared };
PHISHING_REFRESH_PERIOD_MINUTES,
);
return { balance, phishing };
} }
/** /**
@@ -104,10 +124,10 @@ function registerAlarmHandlers(handlers) {
module.exports = { module.exports = {
BALANCE_REFRESH_ALARM, BALANCE_REFRESH_ALARM,
PHISHING_REFRESH_ALARM, OBSOLETE_ALARMS,
MIN_ALARM_PERIOD_MINUTES, MIN_ALARM_PERIOD_MINUTES,
BALANCE_REFRESH_PERIOD_MINUTES, BALANCE_REFRESH_PERIOD_MINUTES,
PHISHING_REFRESH_PERIOD_MINUTES, clearObsoleteAlarms,
ensureAlarm, ensureAlarm,
ensureRecurringAlarms, ensureRecurringAlarms,
registerAlarmHandlers, registerAlarmHandlers,

View File

@@ -602,6 +602,12 @@ const TX_STAGE_BROADCAST = "broadcast";
// may yet succeed, so the one thing the popup must not say is "start again // may yet succeed, so the one thing the popup must not say is "start again
// from the site". // from the site".
const TX_STAGE_INFLIGHT = "inflight"; const TX_STAGE_INFLIGHT = "inflight";
// A transaction refused for a nonce that is already spoken for, either by the
// node's own answer or by this wallet's record of what it has broadcast. It is
// the one broadcast-stage failure that is not ambiguous: the transaction was
// not taken, so the user is told it did not reach the network and to send it
// again, rather than being warned that it might already be out there.
const TX_STAGE_NONCE = "nonce";
function errorText(err) { function errorText(err) {
if (typeof err === "string" && err !== "") return err; if (typeof err === "string" && err !== "") return err;
@@ -611,6 +617,59 @@ function errorText(err) {
return "The transaction could not be sent."; return "The transaction could not be sent.";
} }
// Every string a failure might carry its reason in. ethers reports the node's
// own words in `shortMessage`, but a JSON-RPC error it could not classify is
// nested under `error` or `info.error` with the node's message intact, and the
// classification below has to see that too.
function failureTexts(err) {
if (typeof err === "string") return [err];
if (!err || typeof err !== "object") return [];
const texts = [];
for (const text of [err.shortMessage, err.message, err.reason]) {
if (text) texts.push(String(text));
}
const nested = err.error || (err.info && err.info.error);
if (nested && nested.message) texts.push(String(nested.message));
return texts;
}
// What the Ethereum clients say when a transaction's nonce is already spoken
// for: either it is below the account's next nonce, or another transaction is
// sitting in the pool at that nonce and this one did not outbid it. Either way
// the node answered, and its answer was that it did not take this transaction.
//
// "already known" is deliberately absent. A node that says it knows the
// transaction has it, so that transaction did reach the network and the
// ambiguous broadcast wording is the correct one for it.
const NONCE_COLLISION_PATTERNS = [
/nonce too low/i,
/nonce has already been used/i,
/invalid nonce/i,
/oldnonce/i,
/replacement transaction underpriced/i,
/replacement fee too low/i,
];
// ethers' own classification of the same two conditions.
const NONCE_COLLISION_CODES = ["NONCE_EXPIRED", "REPLACEMENT_UNDERPRICED"];
// Whether a failed send is a nonce collision.
function isNonceCollision(err) {
if (!err) return false;
if (err.code && NONCE_COLLISION_CODES.includes(err.code)) return true;
return failureTexts(err).some((text) =>
NONCE_COLLISION_PATTERNS.some((pattern) => pattern.test(text)),
);
}
// What both the requesting page and the popup are told about a nonce
// collision. The node's own words ("nonce too low") are a fragment and are
// replaced rather than passed through: they are not a sentence, and they say
// less than the wallet knows.
const NONCE_COLLISION_MESSAGE =
"The transaction was not sent, because its nonce had already been used" +
" by another transaction.";
// What the background does with a pending transaction approval after a failed // What the background does with a pending transaction approval after a failed
// attempt: what it tells the popup, and whether the approval is spent // attempt: what it tells the popup, and whether the approval is spent
// (resolved to the requesting page as an error and deleted) or left standing // (resolved to the requesting page as an error and deleted) or left standing
@@ -627,12 +686,31 @@ function errorText(err) {
// that never left from one that is already in the mempool. The approval is // that never left from one that is already in the mempool. The approval is
// spent and the requesting page has been given its outcome; a second // spent and the requesting page has been given its outcome; a second
// attempt against it would report a second outcome for one request. // attempt against it would report a second outcome for one request.
// - nonce: terminal too, and the one case where the wallet does know the
// transaction never left. The approval carries a nonce that is spent, so
// the artifact signed against it can never be accepted and the user is told
// to send it again from the site.
//
// The stage comes back out because a broadcast failure the node blamed on the
// nonce is reclassified here; the caller reports the stage this returns rather
// than the one it passed in.
function describeTxFailure(stage, err) { function describeTxFailure(stage, err) {
if (
stage === TX_STAGE_NONCE ||
(stage === TX_STAGE_BROADCAST && isNonceCollision(err))
) {
return {
error: NONCE_COLLISION_MESSAGE,
retryable: false,
spendApproval: true,
stage: TX_STAGE_NONCE,
};
}
const error = errorText(err); const error = errorText(err);
const retryable = const retryable =
stage === TX_STAGE_SIGN || stage === TX_STAGE_SIGN ||
(stage === TX_STAGE_VERIFY && failureIsRetryable(err)); (stage === TX_STAGE_VERIFY && failureIsRetryable(err));
return { error, retryable, spendApproval: !retryable }; return { error, retryable, spendApproval: !retryable, stage };
} }
// What the popup shows and does after the background reports a failed signing // What the popup shows and does after the background reports a failed signing
@@ -642,14 +720,20 @@ function describeTxFailure(stage, err) {
// //
// A failed broadcast gets its own wording: the transaction may already be on // A failed broadcast gets its own wording: the transaction may already be on
// the network, so telling the user to start again from the site is exactly the // the network, so telling the user to start again from the site is exactly the
// wrong instruction. // wrong instruction. A nonce collision is the exception to that exception —
// the transaction demonstrably did not go out, and saying it might have would
// send the user hunting for a transaction that does not exist.
function describeSigningFailure(response, fallbackMessage) { function describeSigningFailure(response, fallbackMessage) {
let message = (response && response.error) || fallbackMessage; let message = (response && response.error) || fallbackMessage;
if (!/[.!?]$/.test(message)) message += "."; if (!/[.!?]$/.test(message)) message += ".";
const retryable = !!(response && response.retryable); const retryable = !!(response && response.retryable);
const stage = response && response.stage; const stage = response && response.stage;
if (!retryable) { if (!retryable) {
if (stage === TX_STAGE_BROADCAST) { if (stage === TX_STAGE_NONCE) {
message +=
" The transaction did not reach the network." +
" Please send it again from the site.";
} else if (stage === TX_STAGE_BROADCAST) {
message += message +=
" The transaction may still have reached the network." + " The transaction may still have reached the network." +
" Check the account before sending it again."; " Check the account before sending it again.";
@@ -675,9 +759,11 @@ module.exports = {
assertWithinCeilings, assertWithinCeilings,
sameAddress, sameAddress,
failureIsRetryable, failureIsRetryable,
isNonceCollision,
describeTxFailure, describeTxFailure,
describeSigningFailure, describeSigningFailure,
ApprovalMismatchError, ApprovalMismatchError,
NONCE_COLLISION_MESSAGE,
ALLOWED_TX_TYPES, ALLOWED_TX_TYPES,
SERIALIZED_FIELDS, SERIALIZED_FIELDS,
FORBIDDEN_FIELDS, FORBIDDEN_FIELDS,
@@ -686,6 +772,7 @@ module.exports = {
TX_STAGE_VERIFY, TX_STAGE_VERIFY,
TX_STAGE_BROADCAST, TX_STAGE_BROADCAST,
TX_STAGE_INFLIGHT, TX_STAGE_INFLIGHT,
TX_STAGE_NONCE,
MAX_GAS_LIMIT, MAX_GAS_LIMIT,
MAX_FEE_PER_GAS, MAX_FEE_PER_GAS,
}; };

262
src/shared/browserApi.js Normal file
View File

@@ -0,0 +1,262 @@
// The one place in this tree that names `browser` or `chrome`.
//
// The two targets do not agree on the namespace, and they disagree about the
// call shape only in which one is native. Chrome MV3 exposes `chrome.*`,
// where tabs, windows and messaging take a trailing callback and report
// failure through the global `chrome.runtime.lastError`. Firefox MV2 exposes
// `browser.*`, where those same methods return promises — but, measured on
// Firefox 153.0.3, it ALSO honours a trailing Chrome-style callback, returns
// no promise when one is given, and populates `browser.runtime.lastError`.
// The callback code that predated this module therefore ran on both, and
// https://git.eeqj.de/sneak/AutistMask/issues/153 was filed on the belief
// that it did not. This module exists for uniformity, not for repair: the
// tree used to resolve the namespace with a ternary at six call sites and
// then mix promise-form storage with callback-form messaging.
//
// The strategy is promises out, everywhere: one namespace, one call shape,
// composing with the `async` handlers in the background. Callers `await`;
// nothing outside this file has to know which browser it is running on.
//
// Two deliberate asymmetries, because they are what the browsers actually do
// rather than what a uniform-looking shim would pretend:
//
// - Storage is called in its PROMISE form on both namespaces.
// `chrome.storage.local.get()` returns a promise on MV3 and the popup
// already depends on that — src/shared/state.js has always awaited it.
// Wrapping it in a callback here would be a change, not a fix.
// - notify() sends without a callback. It is for a message whose answer
// nobody reads; appending a callback would only manufacture a
// lastError/rejection for a receiver that was never expected to reply.
//
// Everything is resolved on use rather than captured at module load. The MV3
// service worker is torn down and re-evaluated repeatedly, and the unit
// suite installs its stubs on `global.chrome` around a require().
// The extension API namespace, preferring `browser.*` where it exists.
//
// Whole-namespace, never per-method: mixing `browser.tabs` with
// `chrome.windows` would also mix promise and callback semantics inside a
// single call path, which is the bug this module exists to remove.
function extensionApi() {
if (typeof browser !== "undefined" && browser) return browser;
if (typeof chrome !== "undefined" && chrome) return chrome;
return null;
}
// True when the resolved namespace is the promise-flavoured one.
//
// It doubles as "this is the Gecko/MV2 build", which is a second question
// with the same answer and one real caller: src/content/index.js has to
// inject the inpage provider itself there, because MV2 has no
// `"world": "MAIN"` for a manifest-declared content script.
function hasBrowserNamespace() {
return typeof browser !== "undefined" && !!browser;
}
function namespaceMember(name) {
const api = extensionApi();
return (api && api[name]) || null;
}
function runtimeApi() {
return namespaceMember("runtime");
}
function tabsApi() {
return namespaceMember("tabs");
}
function windowsApi() {
return namespaceMember("windows");
}
function alarmsApi() {
return namespaceMember("alarms");
}
// The toolbar button. MV3 calls it `action`, MV2 calls it `browserAction`.
function actionApi() {
const api = extensionApi();
if (!api) return null;
return api.action || api.browserAction || null;
}
// `storage.local`, or null in a context that has no storage permission.
//
// Null rather than a throw for the one caller that genuinely degrades:
// src/shared/phishingDomains.js falls back to its vendored blocklist and does
// its own null check. Everything that reads or writes the wallet goes through
// storageGet()/storageSet(), which reject instead — see there.
function storageLocal() {
const storage = namespaceMember("storage");
return (storage && storage.local) || null;
}
// The callback-path error channel. Read only from inside an appended
// callback, i.e. only on the `chrome.*` path, where it is the sole way a
// failure is reported. The background's three explicit lastError checks are
// gone because invoke() turns it into a rejection before any caller sees it.
function lastError() {
const runtime = runtimeApi();
return (runtime && runtime.lastError) || null;
}
// Call `owner[method](...args)` and return a promise for its result.
//
// On the promise namespace the method already returns one. On the callback
// namespace the callback is appended here and lastError becomes a rejection,
// because a caller holding a promise has nowhere to check a global flag.
function invoke(owner, method, ...args) {
if (!owner || typeof owner[method] !== "function") {
return Promise.reject(
new Error(
"extension API " +
method +
"() is not available in this context",
),
);
}
if (hasBrowserNamespace()) {
try {
return Promise.resolve(owner[method](...args));
} catch (e) {
return Promise.reject(e);
}
}
return new Promise((resolve, reject) => {
owner[method](...args, (result) => {
const err = lastError();
if (err) reject(new Error(err.message || String(err)));
else resolve(result);
});
});
}
/**
* Send a message to the extension's own contexts and resolve with the reply.
*
* Rejects when nothing is listening, on both browsers. A caller that does not
* care must say so — see notify().
*
* @param {Object} message
* @returns {Promise<*>} the receiver's response.
*/
function sendMessage(message) {
return invoke(runtimeApi(), "sendMessage", message);
}
/**
* Send a message nobody is expected to answer, and swallow the fact that
* nobody did.
*
* @param {Object} message
* @returns {void}
*/
function notify(message) {
const runtime = runtimeApi();
if (!runtime || typeof runtime.sendMessage !== "function") return;
const result = runtime.sendMessage(message);
// MV3 hands back a promise for a one-argument send, and it rejects when
// the background is not listening. Unhandled, that surfaces as an error
// the e2e suites fail the run on.
if (result && typeof result.catch === "function") result.catch(() => {});
}
// These two carry the wallet. A missing `storage.local` has to reject and not
// default: resolving {} would make an existing wallet read back as no wallet,
// and resolving a no-op write would discard the user's state with nothing
// logged. A caller that wants to degrade takes storageLocal() directly.
function storageUnavailable(method) {
return Promise.reject(
new Error("extension storage.local is not available: " + method),
);
}
/**
* @param {string|string[]|Object} keys
* @returns {Promise<Object>} the stored items.
* @throws rejects where `storage.local` is absent.
*/
function storageGet(keys) {
const storage = storageLocal();
if (!storage) return storageUnavailable("get");
return Promise.resolve(storage.get(keys));
}
/**
* @param {Object} items
* @returns {Promise<void>}
* @throws rejects where `storage.local` is absent.
*/
function storageSet(items) {
const storage = storageLocal();
if (!storage) return storageUnavailable("set");
return Promise.resolve(storage.set(items));
}
/**
* @param {Object} queryInfo
* @returns {Promise<Array>} the matching tabs.
*/
function tabsQuery(queryInfo) {
return invoke(tabsApi(), "query", queryInfo);
}
/**
* Send a message to one tab's content script.
*
* Rejects for a tab that has no receiver, which is most of them. That
* rejection is the promise-shaped replacement for the runtime.lastError
* checks the broadcast helpers used to make, and callers ignore it the same
* way.
*
* @param {number} tabId
* @param {Object} message
* @returns {Promise<*>}
*/
function tabsSendMessage(tabId, message) {
return invoke(tabsApi(), "sendMessage", tabId, message);
}
/**
* @param {Object} createData
* @returns {Promise<Object>} the created window.
*/
function windowsCreate(createData) {
return invoke(windowsApi(), "create", createData);
}
/**
* @returns {Promise<Object>} the last focused window.
*/
function windowsGetLastFocused() {
return invoke(windowsApi(), "getLastFocused");
}
/**
* @param {number} windowId
* @returns {Promise<void>}
*/
function windowsRemove(windowId) {
return invoke(windowsApi(), "remove", windowId);
}
module.exports = {
actionApi,
alarmsApi,
extensionApi,
hasBrowserNamespace,
notify,
runtimeApi,
sendMessage,
storageGet,
storageLocal,
storageSet,
tabsApi,
tabsQuery,
tabsSendMessage,
windowsApi,
windowsCreate,
windowsGetLastFocused,
windowsRemove,
};

41
src/shared/domainHash.js Normal file
View File

@@ -0,0 +1,41 @@
// The one definition of how a domain becomes a blocklist entry.
//
// The vendored phishing blocklist ships digests, not domain names: see
// phishingDomains.js for why, and script/vendor-blocklist for how the artifact
// is produced. Both sides have to agree exactly — a mismatch would silently
// match nothing, which is a blocklist that quietly protects no one — so the
// rule lives here and is required by both rather than written down twice.
//
// sha256 truncated to 64 bits. Truncation is what keeps the artifact small
// enough to bundle (16 hex characters per entry rather than 64), and 64 bits is
// far past what this has to withstand: over ~10^5 entries the chance that any
// hostname a user visits collides with an entry it is not is about 10^-14 per
// lookup, and a deliberate collision buys an attacker a false phishing warning
// on a site they do not control, not a missed one. For scale, Safe Browsing
// distributes 32-bit prefixes and resolves the rest against a server; this is
// 32 bits more, with no server involved.
const { sha256, toUtf8Bytes } = require("ethers");
const HASH_ALGORITHM = "sha256";
const HASH_HEX_CHARS = 16;
/**
* The blocklist entry for a domain: lowercased, hashed, truncated.
*
* @param {string} domain
* @returns {string} HASH_HEX_CHARS lowercase hex characters, no 0x prefix.
*/
function hashDomain(domain) {
// ethers returns "0x" + 64 hex characters.
return sha256(toUtf8Bytes(domain.toLowerCase())).slice(
2,
2 + HASH_HEX_CHARS,
);
}
module.exports = {
HASH_ALGORITHM,
HASH_HEX_CHARS,
hashDomain,
};

View File

@@ -4,8 +4,7 @@
// //
// POPUP ONLY. localStorage does not exist in the Chrome MV3 service worker, // POPUP ONLY. localStorage does not exist in the Chrome MV3 service worker,
// so this module must not be pulled into src/background/. Anything the // so this module must not be pulled into src/background/. Anything the
// background context needs to cache goes in extension storage instead (see // background context needs to cache goes in extension storage instead.
// shared/phishingDomains.js).
const { getProvider } = require("./balances"); const { getProvider } = require("./balances");
const { log } = require("./log"); const { log } = require("./log");

File diff suppressed because one or more lines are too long

View File

@@ -1,165 +1,109 @@
// Domain-based phishing detection using a vendored blocklist with delta updates. // Domain-based phishing detection against a blocklist vendored at build time.
// //
// A community-maintained phishing domain blocklist is vendored in // The list is produced by script/vendor-blocklist from a hash-pinned upstream
// phishingBlocklist.json and bundled at build time. At runtime, we fetch // commit, committed as phishingBlocklist.json, and bundled. There is no runtime
// the live list periodically and keep only the delta (new entries not in // fetch: the extension asks nobody anything to answer this question, so no third
// the vendored list) in memory. This keeps runtime memory usage small. // party learns which sites a user connects to, and no third party decides what
// this wallet warns about. The cost is staleness — the shipped list is exactly
// as fresh as the last vendoring run that was released — and the refresh path is
// re-running that script and shipping the diff.
// //
// The domain-checker checks the in-memory delta first (fresh/recent scam // The artifact holds digests, not domains: sha256 truncated to 64 bits, one
// sites), then falls back to the vendored list. // entry per 16 hex characters, concatenated in sorted order into a single
// string (see domainHash.js). Three things follow from that shape, and all
// three are the reason for it:
// //
// If the delta and its fetch timestamp fit in 256 KiB they are persisted to // - the extension ships no plaintext list of anyone's domain names, which is
// extension storage, so they survive termination of the MV3 service worker. // what makes a blocklist assembled elsewhere shippable here at all.
// Extension storage, not localStorage: localStorage does not exist in a // - a lookup is a binary search over that string. Nothing is built at module
// service worker, so the previous persistence never ran on Chrome at all. // load, which matters because the MV3 service worker is torn down when idle
// The stored timestamps are what keep a restarted worker from re-fetching on // and re-evaluates this file on every wake.
// every wake while still noticing an overdue update. Those guards apply to the // - the file is 1.7 MB rather than 8.7 MB.
// startup path only; the 24-hour alarm tick bypasses them, or it would veto //
// its own refresh — see updatePhishingList(). // Nothing here is async: callers answer an approval prompt with the result.
const vendoredConfig = require("./phishingBlocklist.json"); const vendored = require("./phishingBlocklist.json");
const { HASH_ALGORITHM, HASH_HEX_CHARS, hashDomain } = require("./domainHash");
const BLOCKLIST_URL = // The artifact is generated, so a shape it does not have is a build fault, not
"https://raw.githubusercontent.com/MetaMask/eth-phishing-detect/main/src/config.json"; // a runtime condition. It is checked anyway, and loudly, because every way of
// getting it wrong — a stale format, a truncated file, a different digest —
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours // produces a blocklist that matches nothing at all while looking perfectly
// healthy. A phishing check that silently answers "no" to everything is the one
// Floor on how often an unscheduled path may hit the network. The worker is // failure this module must not have.
// revived every ~30 seconds while the browser is busy, and every revival runs function checkArtifact(a) {
// the startup path; without a persisted record of the last attempt, any state const bad = (why) =>
// that leaves lastFetchTime unset — a fetch that failed, or a delta too large new Error(
// to store — would download the full list on every single wake. "phishingBlocklist.json " +
const MIN_FETCH_ATTEMPT_INTERVAL_MS = 60 * 60 * 1000; // 1 hour why +
". It is generated by script/vendor-blocklist; re-run that " +
const DELTA_STORAGE_KEY = "phishing-delta"; "rather than editing it.",
const MAX_DELTA_BYTES = 256 * 1024; // 256 KiB
// Vendored set — built once from the bundled JSON.
const vendoredBlacklist = new Set(
(vendoredConfig.blacklist || []).map((d) => d.toLowerCase()),
);
// Delta set — only entries from live list that are NOT in vendored.
let deltaBlacklist = new Set();
let lastFetchTime = 0;
let lastAttemptTime = 0;
let fetchPromise = 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;
}
/**
* Sanitise a timestamp read back from storage.
*
* 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 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 {
const result = await storage.get(DELTA_STORAGE_KEY);
const data = result && result[DELTA_STORAGE_KEY];
if (!data) return;
if (Array.isArray(data.blacklist)) {
deltaBlacklist = new Set(
data.blacklist.map((d) => d.toLowerCase()),
);
}
lastFetchTime = sanitizeTimestamp(data.lastFetchTime);
lastAttemptTime = sanitizeTimestamp(data.lastAttemptTime);
} catch {
// Storage unavailable or corrupt — start empty and re-fetch.
}
}
function ensureDeltaLoaded() {
if (!loadPromise) loadPromise = loadDeltaFromStorage();
return loadPromise;
}
/**
* 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>}
*/
async function saveDeltaToStorage() {
const storage = storageApi();
if (!storage) return;
try {
const data = {
blacklist: Array.from(deltaBlacklist),
lastFetchTime,
lastAttemptTime,
};
const json = JSON.stringify(data);
if (json.length < MAX_DELTA_BYTES) {
await storage.set({ [DELTA_STORAGE_KEY]: data });
} else if (lastAttemptTime > 0) {
await storage.set({ [DELTA_STORAGE_KEY]: { lastAttemptTime } });
} else {
await storage.remove(DELTA_STORAGE_KEY);
}
} catch {
// Storage unavailable — skip silently
}
}
/**
* Load a pre-parsed config and compute the delta against the vendored list.
* Used for both live fetches and testing.
*
* @param {{ blacklist?: string[] }} config
* @returns {Promise<void>} resolves once the delta has been persisted.
*/
function loadConfig(config) {
const liveBlacklist = (config.blacklist || []).map((d) => d.toLowerCase());
// Delta = entries in the live list that are NOT in the vendored list
deltaBlacklist = new Set(
liveBlacklist.filter((d) => !vendoredBlacklist.has(d)),
); );
lastFetchTime = Date.now(); if (!a || typeof a !== "object") throw bad("is not an object");
return saveDeltaToStorage(); if (a.algorithm !== HASH_ALGORITHM) {
throw bad(
"declares algorithm " +
JSON.stringify(a.algorithm) +
", but this build hashes with " +
HASH_ALGORITHM,
);
}
if (a.hashHexChars !== HASH_HEX_CHARS) {
throw bad(
"declares " +
JSON.stringify(a.hashHexChars) +
" hex characters per entry, but this build produces " +
HASH_HEX_CHARS,
);
}
if (typeof a.hashes !== "string") throw bad("has no hashes string");
if (!Number.isInteger(a.count) || a.count < 1) {
throw bad("declares no usable entry count");
}
if (a.hashes.length !== a.count * HASH_HEX_CHARS) {
throw bad(
"holds " +
a.hashes.length +
" hex characters, which is not the " +
a.count * HASH_HEX_CHARS +
" its count of " +
a.count +
" entries requires",
);
}
}
checkArtifact(vendored);
const HASHES = vendored.hashes;
const COUNT = vendored.count;
/**
* Is this digest one of the vendored entries?
*
* Binary search over fixed-width records. The digests are lowercase hex of one
* width, so lexicographic order is numeric order and the artifact is written
* sorted; tests assert that ordering against the committed file, because an
* unsorted artifact would fail lookups silently rather than loudly.
*
* @param {string} hash
* @returns {boolean}
*/
function hashListed(hash) {
let lo = 0;
let hi = COUNT - 1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
const at = HASHES.slice(
mid * HASH_HEX_CHARS,
(mid + 1) * HASH_HEX_CHARS,
);
if (at === hash) return true;
if (at < hash) lo = mid + 1;
else hi = mid - 1;
}
return false;
} }
/** /**
@@ -182,161 +126,33 @@ 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.
*
* 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}
*/ */
function isPhishingDomain(hostname) { function isPhishingDomain(hostname) {
if (!hostname) return false; if (!hostname) return false;
const variants = hostnameVariants(hostname); for (const variant of hostnameVariants(hostname)) {
if (hashListed(hashDomain(variant))) return true;
// Check delta blacklist first (fresh/recent scam sites), then vendored
for (const v of variants) {
if (deltaBlacklist.has(v) || vendoredBlacklist.has(v)) return true;
} }
return false; return false;
} }
/** /**
* Fetch the latest blocklist and compute delta against vendored data. * Return the blocklist size for diagnostics.
* 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>}
*/
async function updatePhishingList({ force = false } = {}) {
// A worker that has just been revived knows nothing until the persisted
// 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;
}
}
// De-duplicate concurrent calls
if (fetchPromise) return fetchPromise;
fetchPromise = (async () => {
lastAttemptTime = Date.now();
try {
const resp = await fetch(BLOCKLIST_URL);
if (!resp.ok) throw new Error("HTTP " + resp.status);
const config = await resp.json();
await loadConfig(config);
} catch {
// Silently fail — vendored list still provides coverage. Persist
// the attempt so a persistently failing fetch is retried on the
// schedule rather than on every wake.
await saveDeltaToStorage();
} finally {
fetchPromise = null;
}
})();
return fetchPromise;
}
/**
* Restore persisted state and fetch if the list is overdue.
*
* 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>}
*/
async function initPhishingList() {
await ensureDeltaLoaded();
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 });
}
/**
* Return the total blocklist size (vendored + delta) for diagnostics.
* *
* @returns {number} * @returns {number}
*/ */
function getBlocklistSize() { function getBlocklistSize() {
return vendoredBlacklist.size + deltaBlacklist.size; return COUNT;
}
/**
* Return the delta blocklist size for diagnostics.
*
* @returns {number}
*/
function getDeltaSize() {
return deltaBlacklist.size;
}
/**
* Reset internal state (for testing).
*/
function _reset() {
deltaBlacklist = new Set();
lastFetchTime = 0;
lastAttemptTime = 0;
fetchPromise = null;
loadPromise = null;
} }
module.exports = { module.exports = {
isPhishingDomain, isPhishingDomain,
updatePhishingList,
refreshPhishingListOnSchedule,
initPhishingList,
loadDeltaFromStorage,
loadConfig,
CACHE_TTL_MS,
MIN_FETCH_ATTEMPT_INTERVAL_MS,
DELTA_STORAGE_KEY,
MAX_DELTA_BYTES,
getBlocklistSize, getBlocklistSize,
getDeltaSize,
hostnameVariants, hostnameVariants,
_reset, // Exposed for testing only: the ends of the search range are where an
// Exposed for testing only // off-by-one hides, and reaching them through isPhishingDomain() would mean
_getVendoredBlacklistSize: () => vendoredBlacklist.size, // knowing which domain hashes to the first or last entry.
_getDeltaBlacklist: () => deltaBlacklist, _hashListed: hashListed,
}; };

View File

@@ -21,7 +21,7 @@ async function refreshPrices() {
const fetched = await getTopTokenPrices(25); const fetched = await getTopTokenPrices(25);
Object.assign(prices, fetched); Object.assign(prices, fetched);
lastFetchedAt = now; lastFetchedAt = now;
} catch (e) { } catch {
// prices stay stale on error // prices stay stale on error
} }
} }
@@ -55,42 +55,77 @@ function formatUsd(amount) {
); );
} }
function getAddressValueUsd(addr) { // What an address is worth, as { usd, partial }.
//
// Prices are fetched for the top 25 tokens only, so an address can hold real
// assets this code has no price for. Adding up the priced ones and calling the
// result the total states a number the holdings do not support: an address
// holding nothing but unpriced tokens comes out at $0.00, which tells the user
// their address is worth nothing when it may hold a great deal. Worth zero and
// worth an unknown amount are separate facts and get separate fields, the same
// way an absent holders_count is not a count of zero.
//
// usd: the value of the holdings a price is known for, or null when
// nothing is knowable at all — testnet, or before the first fetch.
// partial: the address also holds a token with no price, so usd is a floor
// and not the total.
//
// Render it through formatAddressTotal() rather than reading usd alone.
function getAddressValue(addr) {
const { currentNetwork } = require("./state"); const { currentNetwork } = require("./state");
if (currentNetwork().isTestnet) return null; if (currentNetwork().isTestnet) return { usd: null, partial: false };
if (!prices.ETH) return null; if (!prices.ETH) return { usd: null, partial: false };
let total = 0; let usd = parseFloat(addr.balance || "0") * prices.ETH;
const ethBal = parseFloat(addr.balance || "0"); let partial = false;
total += ethBal * prices.ETH;
for (const token of addr.tokenBalances || []) { for (const token of addr.tokenBalances || []) {
const tokenBal = parseFloat(token.balance || "0"); const tokenBal = parseFloat(token.balance || "0");
if (tokenBal > 0 && prices[token.symbol]) { // A balance of zero is not a holding: it can neither add to the total
total += tokenBal * prices[token.symbol]; // nor make it incomplete.
if (!(tokenBal > 0)) continue;
if (prices[token.symbol]) {
usd += tokenBal * prices[token.symbol];
} else {
partial = true;
} }
} }
return total; return { usd, partial };
} }
function getWalletValueUsd(wallet) { // The same pair for a whole wallet, and for every wallet at once. One
const { currentNetwork } = require("./state"); // unpriced holding anywhere makes the sum a floor, so partial carries up.
if (currentNetwork().isTestnet) return null; function getWalletValue(wallet) {
if (!prices.ETH) return null; return sumValues(wallet.addresses.map(getAddressValue));
let total = 0;
for (const addr of wallet.addresses) {
total += getAddressValueUsd(addr);
}
return total;
} }
function getTotalValueUsd(wallets) { function getTotalValue(wallets) {
const { currentNetwork } = require("./state"); return sumValues(wallets.map(getWalletValue));
if (currentNetwork().isTestnet) return null; }
if (!prices.ETH) return null;
let total = 0; function sumValues(values) {
for (const wallet of wallets) { let usd = null;
total += getWalletValueUsd(wallet); let partial = false;
for (const value of values) {
if (value.usd === null) continue;
usd = (usd === null ? 0 : usd) + value.usd;
partial = partial || value.partial;
} }
return total; return { usd, partial };
}
// The one rendering of an address total, so no screen says it differently.
//
// A partial total is shown and named as partial: the figure is the ETH and
// priced tokens the user does hold, which is worth having, and suppressing it
// would throw away a number that is correct as far as it goes. What is never
// shown is a figure covering no holdings at all — the $0.00 sum of an empty
// set beside a list of tokens is the bug this replaces.
function formatAddressTotal(value) {
if (!value || value.usd === null) return "";
if (!value.partial) return "Total: " + formatUsd(value.usd);
if (value.usd > 0) {
return "Total: " + formatUsd(value.usd) + " plus unpriced tokens";
}
return "Total: unpriced tokens only";
} }
module.exports = { module.exports = {
@@ -99,7 +134,8 @@ module.exports = {
clearPrices, clearPrices,
getPrice, getPrice,
formatUsd, formatUsd,
getAddressValueUsd, formatAddressTotal,
getWalletValueUsd, getAddressValue,
getTotalValueUsd, getWalletValue,
getTotalValue,
}; };

View File

@@ -5,10 +5,7 @@ const { networkById } = require("./networks");
// Dependency-free constant module; safe to pull into a background bundle. // Dependency-free constant module; safe to pull into a background bundle.
const { RESTORABLE_VIEWS } = require("../popup/restorableViews"); const { RESTORABLE_VIEWS } = require("../popup/restorableViews");
const storageApi = const { storageGet, storageSet } = require("./browserApi");
typeof browser !== "undefined"
? browser.storage.local
: chrome.storage.local;
const DEFAULT_STATE = { const DEFAULT_STATE = {
hasWallet: false, hasWallet: false,
@@ -114,11 +111,11 @@ async function saveState() {
viewData: state.viewData, viewData: state.viewData,
viewStack: state.viewStack, viewStack: state.viewStack,
}; };
await storageApi.set({ autistmask: persisted }); await storageSet({ autistmask: persisted });
} }
async function loadState() { async function loadState() {
const result = await storageApi.get("autistmask"); const result = await storageGet("autistmask");
if (result.autistmask) { if (result.autistmask) {
const saved = result.autistmask; const saved = result.autistmask;
state.wallets = saved.wallets || []; state.wallets = saved.wallets || [];

View File

@@ -82,7 +82,7 @@ function toFixedPoint(value) {
if (text === "") return null; if (text === "") return null;
try { try {
return parseUnits(text, SCALE_DECIMALS); return parseUnits(text, SCALE_DECIMALS);
} catch (e) { } catch {
return null; return null;
} }
} }

View File

@@ -102,6 +102,11 @@ function decodeV2SwapExactIn(input) {
// Decode V2_SWAP_EXACT_OUT (command 0x09) input bytes. // Decode V2_SWAP_EXACT_OUT (command 0x09) input bytes.
// ABI: (address recipient, uint256 amountOut, uint256 amountInMax, // ABI: (address recipient, uint256 amountOut, uint256 amountInMax,
// address[] path, bool payerIsUser) // address[] path, bool payerIsUser)
//
// Nothing calls this: decode() has no 0x09 arm, so a V2 exact-out swap gets
// its command name and no token or amount detail. Kept for the fix, which is
// https://git.eeqj.de/sneak/AutistMask/issues/283.
// eslint-disable-next-line no-unused-vars
function decodeV2SwapExactOut(input) { function decodeV2SwapExactOut(input) {
try { try {
const d = coder.decode( const d = coder.decode(

View File

@@ -57,7 +57,7 @@ async function cryptoBackend() {
try { try {
await WebAssembly.compile(EMPTY_WASM_MODULE); await WebAssembly.compile(EMPTY_WASM_MODULE);
return "wasm"; return "wasm";
} catch (_) { } catch {
return "asmjs"; return "asmjs";
} }
} }

View File

@@ -1,6 +1,8 @@
// Wallet and address deletion state transitions, kept out of the views so the // Wallet and address deletion state transitions, kept out of the views so the
// selection and broadcast rules are testable without a DOM. // selection and broadcast rules are testable without a DOM.
const { notify } = require("./browserApi");
// Two records of the same address can be stored in different cases, so // Two records of the same address can be stored in different cases, so
// address equality is never a literal string comparison. // address equality is never a literal string comparison.
function sameAddress(a, b) { function sameAddress(a, b) {
@@ -144,9 +146,7 @@ function removeAddressFromState(state, walletIdx, addrIdx) {
// accountsChanged to connected sites. Same call shape as the address // accountsChanged to connected sites. Same call shape as the address
// switch in the home view. // switch in the home view.
function broadcastActiveChanged() { function broadcastActiveChanged() {
const runtime = notify({ type: "AUTISTMASK_ACTIVE_CHANGED" });
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
runtime.sendMessage({ type: "AUTISTMASK_ACTIVE_CHANGED" });
} }
module.exports = { module.exports = {

238
tests/addressValue.test.js Normal file
View File

@@ -0,0 +1,238 @@
// The USD total of an address that holds something this build cannot price
// (issue #261).
//
// Prices exist for the top 25 tokens only, so an address can hold real assets
// with no price attached. Summing what is priced and printing the result as
// the total says "$0.00" for an address holding nothing but unpriced tokens —
// worth-nothing and worth-an-unknown-amount collapsed into one number, in the
// direction that matters. The two are separate facts here, the same way an
// absent holders_count is not a count of zero.
//
// The value and its rendering are asserted directly, and then through the two
// call sites that return their markup as a string: the wallet list on Home and
// the balance warning on the address-removal confirmation. AddressDetail and
// the Home summary line render into the DOM and are covered by tests/e2e.
// helpers.js pulls in state.js, which reads chrome.storage.local at load.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const {
prices,
clearPrices,
getAddressValue,
getWalletValue,
getTotalValue,
formatAddressTotal,
} = require("../src/shared/prices");
const { state } = require("../src/shared/state");
const { walletListHtml } = require("../src/popup/views/home");
const { balanceWarningHtml } = require("../src/popup/views/deleteAddress");
const USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
const NOVEL = "0x1111111111111111111111111111111111111111";
// No ETH, and a token no price is known for. The case the user is told is
// worth $0.00 today.
const UNPRICED_ONLY = {
address: "0x" + "a".repeat(40),
balance: "0",
tokenBalances: [{ address: NOVEL, symbol: "NOVEL", balance: "5000.0" }],
};
// Nothing at all: the address really is worth zero.
const EMPTY = {
address: "0x" + "b".repeat(40),
balance: "0",
tokenBalances: [],
};
// Every holding priced.
const FULLY_PRICED = {
address: "0x" + "c".repeat(40),
balance: "1.5",
tokenBalances: [{ address: USDC, symbol: "USDC", balance: "2500.0" }],
};
// Part priced, part not: 1.5 ETH plus a token with no price.
const PARTLY_PRICED = {
address: "0x" + "d".repeat(40),
balance: "1.5",
tokenBalances: [{ address: NOVEL, symbol: "NOVEL", balance: "5000.0" }],
};
beforeEach(() => {
clearPrices();
prices.ETH = 2000;
prices.USDC = 1;
state.wallets = [];
state.trackedTokens = [];
state.showZeroBalanceTokens = false;
state.activeAddress = null;
});
afterEach(() => {
clearPrices();
});
// The total line only, in each of the two markup-returning call sites. The
// ETH balance line above it legitimately reads $0.00 for an address with no
// ETH, so the assertions have to name the line under test.
function walletListTotal(addr) {
state.wallets = [{ name: "Wallet 1", type: "hd", addresses: [addr] }];
const match = walletListHtml().match(/min-h-\[1rem\]">([^<]*)</);
return match && match[1];
}
function removalWarningTotal(addr) {
const match = balanceWarningHtml(addr).match(/mt-1">([^<]*)</);
return match && match[1];
}
describe("the value of an address, and whether it is the whole value", () => {
test("an address holding only unpriced tokens has an incomplete value", () => {
expect(getAddressValue(UNPRICED_ONLY)).toEqual({
usd: 0,
partial: true,
});
});
test("an address holding nothing is complete, and zero", () => {
expect(getAddressValue(EMPTY)).toEqual({ usd: 0, partial: false });
});
test("a fully priced address is complete, and unchanged", () => {
expect(getAddressValue(FULLY_PRICED)).toEqual({
usd: 5500,
partial: false,
});
});
test("a partly priced address keeps the part it can price", () => {
expect(getAddressValue(PARTLY_PRICED)).toEqual({
usd: 3000,
partial: true,
});
});
// A token balance of zero is not a holding, so it cannot make the total
// incomplete: an address with a spent-out unpriced token is worth zero.
test("a zero balance in an unpriced token leaves the value complete", () => {
const addr = {
address: "0x1",
balance: "0",
tokenBalances: [{ address: NOVEL, symbol: "NOVEL", balance: "0" }],
};
expect(getAddressValue(addr)).toEqual({ usd: 0, partial: false });
});
// Before the first price fetch, and on testnet, nothing is knowable: that
// is a third state, and it stays distinct from both of the others.
test("no prices at all means no value, not an incomplete one", () => {
clearPrices();
expect(getAddressValue(FULLY_PRICED)).toEqual({
usd: null,
partial: false,
});
});
test("one unpriced holding makes a wallet and the grand total partial", () => {
const wallet = { addresses: [FULLY_PRICED, UNPRICED_ONLY] };
expect(getWalletValue(wallet)).toEqual({ usd: 5500, partial: true });
expect(getTotalValue([wallet])).toEqual({ usd: 5500, partial: true });
});
test("a wallet of fully priced addresses stays complete", () => {
const wallet = { addresses: [FULLY_PRICED, EMPTY] };
expect(getWalletValue(wallet)).toEqual({ usd: 5500, partial: false });
});
});
describe("how that value is written on screen", () => {
test("a complete total is the figure", () => {
expect(formatAddressTotal(getAddressValue(FULLY_PRICED))).toBe(
"Total: $5,500.00",
);
});
test("an address worth zero says so", () => {
expect(formatAddressTotal(getAddressValue(EMPTY))).toBe("Total: $0.00");
});
// The figure is still worth having — it is the ETH the user does hold —
// but on its own it understates the address, so it is named as partial.
test("a partly priced total is given, and marked as partial", () => {
expect(formatAddressTotal(getAddressValue(PARTLY_PRICED))).toBe(
"Total: $3,000.00 plus unpriced tokens",
);
});
// Nothing priced is held, so there is no figure to give: printing the
// $0.00 sum of an empty set is the bug.
test("a total with nothing priced in it gives no figure", () => {
const line = formatAddressTotal(getAddressValue(UNPRICED_ONLY));
expect(line).toBe("Total: unpriced tokens only");
expect(line).not.toContain("$");
});
test("an unknown value is written as nothing at all", () => {
clearPrices();
expect(formatAddressTotal(getAddressValue(FULLY_PRICED))).toBe("");
});
});
describe("the wallet list on Home", () => {
test("an address holding only unpriced tokens is not totalled at $0.00", () => {
expect(walletListTotal(UNPRICED_ONLY)).toBe(
"Total: unpriced tokens only",
);
});
test("an address holding nothing is still totalled at $0.00", () => {
expect(walletListTotal(EMPTY)).toBe("Total: $0.00");
});
test("a fully priced address shows its total", () => {
expect(walletListTotal(FULLY_PRICED)).toBe("Total: $5,500.00");
});
test("a partly priced address shows the priced part, marked partial", () => {
expect(walletListTotal(PARTLY_PRICED)).toBe(
"Total: $3,000.00 plus unpriced tokens",
);
});
test("an address whose value is unknown keeps its blank line", () => {
clearPrices();
expect(walletListTotal(FULLY_PRICED)).toBe("&nbsp;");
});
});
describe("the balance warning on the address-removal confirmation", () => {
// "This address holds a balance." followed by "Total: $0.00" is a flat
// contradiction, on the one screen whose job is to warn.
test("an address holding only unpriced tokens is not totalled at $0.00", () => {
expect(balanceWarningHtml(UNPRICED_ONLY)).toContain(
"This address holds a balance.",
);
expect(removalWarningTotal(UNPRICED_ONLY)).toBe(
"Total: unpriced tokens only",
);
});
test("a fully priced address still shows its total", () => {
expect(removalWarningTotal(FULLY_PRICED)).toBe("Total: $5,500.00");
});
test("a partly priced address shows the priced part, marked partial", () => {
expect(removalWarningTotal(PARTLY_PRICED)).toBe(
"Total: $3,000.00 plus unpriced tokens",
);
});
test("no total line is written when the value is unknown", () => {
clearPrices();
expect(removalWarningTotal(FULLY_PRICED)).toBe(null);
});
});

View File

@@ -80,17 +80,12 @@ describe("alarms module", () => {
delete global.chrome; delete global.chrome;
}); });
test("ensureRecurringAlarms schedules both recurring jobs", async () => { test("ensureRecurringAlarms schedules the recurring job", async () => {
const created = await alarmsMod.ensureRecurringAlarms(); const created = await alarmsMod.ensureRecurringAlarms();
expect(created).toEqual({ balance: true, phishing: true }); expect(created).toEqual({ balance: true, cleared: [] });
const names = alarmsStub.created.map((c) => c.name).sort(); const names = alarmsStub.created.map((c) => c.name);
expect(names).toEqual( expect(names).toEqual([alarmsMod.BALANCE_REFRESH_ALARM]);
[
alarmsMod.BALANCE_REFRESH_ALARM,
alarmsMod.PHISHING_REFRESH_ALARM,
].sort(),
);
}); });
test("the balance refresh keeps its 60-second cadence", async () => { test("the balance refresh keeps its 60-second cadence", async () => {
@@ -99,12 +94,35 @@ describe("alarms module", () => {
expect(balance.periodInMinutes).toBe(1); expect(balance.periodInMinutes).toBe(1);
}); });
test("the phishing refresh keeps its 24-hour cadence", async () => { test("a retired job's alarm is cleared, not left running", async () => {
// The browser holds an alarm until something clears it. Deleting the
// job from the code is not enough: on every install that ever ran the
// version which created it, the alarm goes on waking the service
// worker on its old schedule with nothing to deliver it to.
for (const name of alarmsMod.OBSOLETE_ALARMS) {
alarmsStub.create(name, { periodInMinutes: 24 * 60 });
}
expect(alarmsMod.OBSOLETE_ALARMS.length).toBeGreaterThan(0);
const result = await alarmsMod.ensureRecurringAlarms();
expect(result.cleared).toEqual(alarmsMod.OBSOLETE_ALARMS);
for (const name of alarmsMod.OBSOLETE_ALARMS) {
expect(alarmsStub.alarms.get(name)).toBeUndefined();
}
});
test("clearing a retired alarm is not re-reported once it is gone", async () => {
await alarmsMod.ensureRecurringAlarms(); await alarmsMod.ensureRecurringAlarms();
const phishing = alarmsStub.alarms.get( const again = await alarmsMod.ensureRecurringAlarms();
alarmsMod.PHISHING_REFRESH_ALARM, expect(again.cleared).toEqual([]);
});
test("no retired name is also a live one", async () => {
// A name in both lists would be created and then cleared on every
// start, so the job it schedules would never fire.
expect(alarmsMod.OBSOLETE_ALARMS).not.toContain(
alarmsMod.BALANCE_REFRESH_ALARM,
); );
expect(phishing.periodInMinutes).toBe(24 * 60);
}); });
test("no period is below the browser-enforced minimum", async () => { test("no period is below the browser-enforced minimum", async () => {
@@ -122,14 +140,14 @@ describe("alarms module", () => {
test("a revived worker does not reset an existing alarm's schedule", async () => { test("a revived worker does not reset an existing alarm's schedule", async () => {
await alarmsMod.ensureRecurringAlarms(); await alarmsMod.ensureRecurringAlarms();
expect(alarmsStub.create).toHaveBeenCalledTimes(2); expect(alarmsStub.create).toHaveBeenCalledTimes(1);
// Every wake re-runs the startup path. Re-creating an alarm restarts // Every wake re-runs the startup path. Re-creating an alarm restarts
// its period, so a busy extension would push the next fire out // its period, so a busy extension would push the next fire out
// forever and the job would never run. // forever and the job would never run.
const again = await alarmsMod.ensureRecurringAlarms(); const again = await alarmsMod.ensureRecurringAlarms();
expect(again).toEqual({ balance: false, phishing: false }); expect(again).toEqual({ balance: false, cleared: [] });
expect(alarmsStub.create).toHaveBeenCalledTimes(2); expect(alarmsStub.create).toHaveBeenCalledTimes(1);
}); });
test("a missing alarm is re-created on the next start", async () => { test("a missing alarm is re-created on the next start", async () => {
@@ -137,7 +155,7 @@ describe("alarms module", () => {
await alarmsStub.clear(alarmsMod.BALANCE_REFRESH_ALARM); await alarmsStub.clear(alarmsMod.BALANCE_REFRESH_ALARM);
const again = await alarmsMod.ensureRecurringAlarms(); const again = await alarmsMod.ensureRecurringAlarms();
expect(again).toEqual({ balance: true, phishing: false }); expect(again).toEqual({ balance: true, cleared: [] });
expect( expect(
alarmsStub.alarms.get(alarmsMod.BALANCE_REFRESH_ALARM), alarmsStub.alarms.get(alarmsMod.BALANCE_REFRESH_ALARM),
).toBeDefined(); ).toBeDefined();
@@ -147,17 +165,17 @@ describe("alarms module", () => {
// An install carries its alarms across an extension update, so a // An install carries its alarms across an extension update, so a
// period changed in a new release only ever reaches users if the // period changed in a new release only ever reaches users if the
// stale one is reconciled. // stale one is reconciled.
alarmsStub.create(alarmsMod.PHISHING_REFRESH_ALARM, { alarmsStub.create(alarmsMod.BALANCE_REFRESH_ALARM, {
periodInMinutes: 7 * 24 * 60, periodInMinutes: 7 * 24 * 60,
}); });
alarmsStub.create.mockClear(); alarmsStub.create.mockClear();
const created = await alarmsMod.ensureRecurringAlarms(); const created = await alarmsMod.ensureRecurringAlarms();
expect(created.phishing).toBe(true); expect(created.balance).toBe(true);
expect( expect(
alarmsStub.alarms.get(alarmsMod.PHISHING_REFRESH_ALARM) alarmsStub.alarms.get(alarmsMod.BALANCE_REFRESH_ALARM)
.periodInMinutes, .periodInMinutes,
).toBe(alarmsMod.PHISHING_REFRESH_PERIOD_MINUTES); ).toBe(alarmsMod.BALANCE_REFRESH_PERIOD_MINUTES);
}); });
test("reconciling a period settles instead of re-creating forever", async () => { test("reconciling a period settles instead of re-creating forever", async () => {
@@ -168,31 +186,31 @@ describe("alarms module", () => {
alarmsStub.create.mockClear(); alarmsStub.create.mockClear();
const again = await alarmsMod.ensureRecurringAlarms(); const again = await alarmsMod.ensureRecurringAlarms();
expect(again).toEqual({ balance: false, phishing: false }); expect(again).toEqual({ balance: false, cleared: [] });
expect(alarmsStub.create).not.toHaveBeenCalled(); expect(alarmsStub.create).not.toHaveBeenCalled();
}); });
test("handlers are dispatched by alarm name from one listener", () => { test("handlers are dispatched by alarm name from one listener", () => {
const balance = jest.fn(); const balance = jest.fn();
const phishing = jest.fn(); const other = jest.fn();
expect( expect(
alarmsMod.registerAlarmHandlers({ alarmsMod.registerAlarmHandlers({
[alarmsMod.BALANCE_REFRESH_ALARM]: balance, [alarmsMod.BALANCE_REFRESH_ALARM]: balance,
[alarmsMod.PHISHING_REFRESH_ALARM]: phishing, "autistmask-some-other-job": other,
}), }),
).toBe(true); ).toBe(true);
expect(alarmsStub.listenerCount()).toBe(1); expect(alarmsStub.listenerCount()).toBe(1);
alarmsStub.fire(alarmsMod.BALANCE_REFRESH_ALARM); alarmsStub.fire(alarmsMod.BALANCE_REFRESH_ALARM);
expect(balance).toHaveBeenCalledTimes(1); expect(balance).toHaveBeenCalledTimes(1);
expect(phishing).not.toHaveBeenCalled(); expect(other).not.toHaveBeenCalled();
alarmsStub.fire(alarmsMod.PHISHING_REFRESH_ALARM); alarmsStub.fire("autistmask-some-other-job");
expect(phishing).toHaveBeenCalledTimes(1); expect(other).toHaveBeenCalledTimes(1);
alarmsStub.fire("some-other-extension-alarm"); alarmsStub.fire("an-alarm-with-no-handler");
expect(balance).toHaveBeenCalledTimes(1); expect(balance).toHaveBeenCalledTimes(1);
expect(phishing).toHaveBeenCalledTimes(1); expect(other).toHaveBeenCalledTimes(1);
}); });
test("Firefox MV2 gets the same treatment via browser.alarms", async () => { test("Firefox MV2 gets the same treatment via browser.alarms", async () => {
@@ -205,8 +223,8 @@ describe("alarms module", () => {
try { try {
const mod = require("../src/shared/alarms"); const mod = require("../src/shared/alarms");
const created = await mod.ensureRecurringAlarms(); const created = await mod.ensureRecurringAlarms();
expect(created).toEqual({ balance: true, phishing: true }); expect(created).toEqual({ balance: true, cleared: [] });
expect(firefoxAlarms.created).toHaveLength(2); expect(firefoxAlarms.created).toHaveLength(1);
// The Chrome stub must not have been touched. // The Chrome stub must not have been touched.
expect(alarmsStub.create).not.toHaveBeenCalled(); expect(alarmsStub.create).not.toHaveBeenCalled();
} finally { } finally {
@@ -220,7 +238,7 @@ describe("alarms module", () => {
const mod = require("../src/shared/alarms"); const mod = require("../src/shared/alarms");
await expect(mod.ensureRecurringAlarms()).resolves.toEqual({ await expect(mod.ensureRecurringAlarms()).resolves.toEqual({
balance: false, balance: false,
phishing: false, cleared: [],
}); });
expect(mod.registerAlarmHandlers({})).toBe(false); expect(mod.registerAlarmHandlers({})).toBe(false);
}); });
@@ -274,9 +292,12 @@ function loadBackground(initialStore = {}) {
tabs: { query: jest.fn(), sendMessage: jest.fn() }, tabs: { query: jest.fn(), sendMessage: jest.fn() },
action: { setPopup: jest.fn() }, action: { setPopup: jest.fn() },
}; };
// Present so that a startup path which went to the network would be
// recorded rather than throwing, which is what makes "no request was made"
// an observation instead of an assumption.
global.fetch = jest.fn(async () => ({ global.fetch = jest.fn(async () => ({
ok: true, ok: true,
json: async () => ({ blacklist: [] }), json: async () => ({}),
})); }));
jest.resetModules(); jest.resetModules();
require("../src/background/index"); require("../src/background/index");
@@ -318,17 +339,21 @@ describe("background worker scheduling", () => {
// Let the startup path's promises settle. // Let the startup path's promises settle.
await settle(); await settle();
const names = alarmsStub.created.map((c) => c.name).sort(); const names = alarmsStub.created.map((c) => c.name);
const { const { BALANCE_REFRESH_ALARM } = require("../src/shared/alarms");
BALANCE_REFRESH_ALARM, expect(names).toEqual([BALANCE_REFRESH_ALARM]);
PHISHING_REFRESH_ALARM,
} = require("../src/shared/alarms");
expect(names).toEqual(
[BALANCE_REFRESH_ALARM, PHISHING_REFRESH_ALARM].sort(),
);
expect(mockSetIntervalCalls).toBe(0); expect(mockSetIntervalCalls).toBe(0);
}); });
test("startup contacts nothing", async () => {
// The phishing blocklist is vendored at build time and there is no
// other startup fetch, so a worker coming up asks nobody anything.
// Every wake used to be a candidate for a blocklist download.
loadBackground();
await settle();
expect(global.fetch).not.toHaveBeenCalled();
});
test("an onAlarm listener is installed on startup", async () => { test("an onAlarm listener is installed on startup", async () => {
alarmsStub = loadBackground().alarmsStub; alarmsStub = loadBackground().alarmsStub;
await settle(); await settle();
@@ -348,7 +373,7 @@ describe("background worker scheduling", () => {
alarmsStub.created.length = 0; alarmsStub.created.length = 0;
loaded.listeners.onStartup[0](); loaded.listeners.onStartup[0]();
await settle(); await settle();
expect(alarmsStub.created).toHaveLength(2); expect(alarmsStub.created).toHaveLength(1);
}); });
test("the install-time listener and the top-level call share one run", async () => { test("the install-time listener and the top-level call share one run", async () => {
@@ -360,13 +385,10 @@ describe("background worker scheduling", () => {
loaded.listeners.onInstalled[0](); loaded.listeners.onInstalled[0]();
await settle(); await settle();
expect(alarmsStub.created).toHaveLength(2); expect(alarmsStub.created).toHaveLength(1);
expect(alarmsStub.created.map((c) => c.name).sort()).toEqual( expect(alarmsStub.created.map((c) => c.name)).toEqual([
[
"autistmask-balance-refresh", "autistmask-balance-refresh",
"autistmask-phishing-refresh", ]);
].sort(),
);
}); });
}); });

View File

@@ -14,8 +14,10 @@ const {
assertWithinCeilings, assertWithinCeilings,
sameAddress, sameAddress,
failureIsRetryable, failureIsRetryable,
isNonceCollision,
describeTxFailure, describeTxFailure,
describeSigningFailure, describeSigningFailure,
NONCE_COLLISION_MESSAGE,
ALLOWED_TX_TYPES, ALLOWED_TX_TYPES,
SERIALIZED_FIELDS, SERIALIZED_FIELDS,
FORBIDDEN_FIELDS, FORBIDDEN_FIELDS,
@@ -23,6 +25,7 @@ const {
TX_STAGE_SIGN, TX_STAGE_SIGN,
TX_STAGE_VERIFY, TX_STAGE_VERIFY,
TX_STAGE_BROADCAST, TX_STAGE_BROADCAST,
TX_STAGE_NONCE,
MAX_GAS_LIMIT, MAX_GAS_LIMIT,
MAX_FEE_PER_GAS, MAX_FEE_PER_GAS,
} = require("../src/shared/approvalVerify"); } = require("../src/shared/approvalVerify");
@@ -1191,7 +1194,6 @@ describe("signing failure and retry", () => {
"already known", "already known",
"timeout of 30000ms exceeded", "timeout of 30000ms exceeded",
"could not coalesce error", "could not coalesce error",
"replacement transaction underpriced",
]) { ]) {
const outcome = describeTxFailure( const outcome = describeTxFailure(
TX_STAGE_BROADCAST, TX_STAGE_BROADCAST,
@@ -1199,10 +1201,76 @@ describe("signing failure and retry", () => {
); );
expect(outcome.retryable).toBe(false); expect(outcome.retryable).toBe(false);
expect(outcome.spendApproval).toBe(true); expect(outcome.spendApproval).toBe(true);
expect(outcome.stage).toBe(TX_STAGE_BROADCAST);
expect(outcome.error).toBe(message); expect(outcome.error).toBe(message);
} }
}); });
// The one broadcast failure that is not ambiguous. The node answered, and
// its answer was that the nonce was already spoken for, so this
// transaction is not in a mempool anywhere.
test("a nonce the node refused is classified however it was worded", () => {
for (const err of [
new Error("nonce too low"),
new Error("replacement transaction underpriced"),
Object.assign(new Error("could not coalesce error"), {
code: "NONCE_EXPIRED",
}),
Object.assign(new Error("could not coalesce error"), {
code: "REPLACEMENT_UNDERPRICED",
}),
// The shape ethers hands up when it could not classify the node's
// error itself: the node's own words are nested underneath.
Object.assign(new Error("could not coalesce error"), {
info: { error: { code: -32000, message: "OldNonce" } },
}),
]) {
const outcome = describeTxFailure(TX_STAGE_BROADCAST, err);
expect(
describeSigningFailure(
outcome,
"The transaction could not be sent.",
).message,
).toMatch(/did not reach the network/);
expect(outcome.retryable).toBe(false);
expect(outcome.spendApproval).toBe(true);
expect(outcome.error).toBe(NONCE_COLLISION_MESSAGE);
expect(outcome.stage).toBe(TX_STAGE_NONCE);
expect(isNonceCollision(err)).toBe(true);
}
});
// A node that says it knows the transaction has it, so it did reach the
// network and the ambiguous wording is the correct one.
test("already known is not a nonce collision", () => {
const err = new Error("already known");
const outcome = describeTxFailure(TX_STAGE_BROADCAST, err);
expect(
describeSigningFailure(
outcome,
"The transaction could not be sent.",
).message,
).toMatch(/may still have reached the network/);
expect(outcome.stage).toBe(TX_STAGE_BROADCAST);
expect(isNonceCollision(err)).toBe(false);
});
test("a nonce collision says the transaction did not reach the network", () => {
const outcome = describeTxFailure(
TX_STAGE_BROADCAST,
new Error("nonce too low"),
);
const copy = describeSigningFailure(
outcome,
"The transaction could not be sent.",
);
expect(copy.retryable).toBe(false);
expect(copy.message).toMatch(/did not reach the network/);
expect(copy.message).not.toMatch(/may still have reached the network/);
expect(copy.message).toMatch(/Please send it again from the site\.$/);
expect(copy.message).toMatch(/^[A-Z].*\.$/);
});
test("a failed broadcast does not tell the user to send it again", () => { test("a failed broadcast does not tell the user to send it again", () => {
const outcome = describeSigningFailure( const outcome = describeSigningFailure(
{ {

File diff suppressed because it is too large Load Diff

View File

@@ -150,7 +150,7 @@ describe("the balance warning on the removal confirmation", () => {
expect(balanceWarningHtml(ETH_ONLY)).toContain("Total: $3,000.00"); expect(balanceWarningHtml(ETH_ONLY)).toContain("Total: $3,000.00");
}); });
// getAddressValueUsd() returns null on testnet and before the first // getAddressValue() reports no value on testnet and before the first
// price fetch. A "Total: $0.00" there would be a lie about the holdings. // price fetch. A "Total: $0.00" there would be a lie about the holdings.
test("no USD total is shown when prices are not known", () => { test("no USD total is shown when prices are not known", () => {
expect(balanceWarningHtml(TOKEN_ONLY)).not.toContain("Total:"); expect(balanceWarningHtml(TOKEN_ONLY)).not.toContain("Total:");

34
tests/e2e/Dockerfile Normal file
View File

@@ -0,0 +1,34 @@
# Chrome end-to-end image: the pinned Playwright image with this repo and a
# freshly built extension inside it, built by script/test-e2e. The suite is
# still started with `docker run`, so every runtime flag the harness needs
# (--ipc=host in particular) applies as before.
#
# The repo is baked in rather than bind-mounted because a bind mount does
# not resolve under Gitea Actions: the runner runs the job in a container
# against the HOST's docker socket, so the source side of a -v is resolved
# by the host daemon while the job's checkout lives on a docker volume that
# is not a host path -- the mount silently succeeds and /work is empty. A
# build context is streamed to the daemon and so works from anywhere.
# Building the extension here too means the machine starting a run needs
# docker and nothing else.
# 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.
FROM mcr.microsoft.com/playwright@sha256:35246d87a7c88ea9b771c65d33171b2611b02a8253b4b12ce6f94376c55f99f2
WORKDIR /work
# Same layering as the root Dockerfile: script/bootstrap installs the
# prerequisites and the dependencies, and the manifests are copied first so
# that layer is cached until they change.
COPY script/ script/
COPY package.json yarn.lock ./
RUN script/bootstrap
COPY . .
RUN make build

View File

@@ -1,10 +1,24 @@
# Firefox end-to-end image: stock Firefox plus geckodriver on a node base, # Firefox end-to-end image: stock Firefox plus geckodriver on a node base,
# built by script/test-e2e-firefox. The repo is bind-mounted at /work; the # with this repo and a freshly built extension inside it, built by
# harness itself has no dependencies, so nothing is installed for it. # script/test-e2e-firefox. The harness itself has no dependencies, so
# nothing is installed for it.
# #
# All three external artifacts are pinned by digest. The Firefox version in # The build context is the repo root. The repo is baked in rather than
# particular must not float: -remote-allow-system-access is mandatory on 153 # bind-mounted because a bind mount does not resolve under Gitea Actions:
# and was not on 142, so the flag the harness passes is version-coupled. # the runner runs the job in a container against the HOST's docker socket,
# so the source side of a -v is resolved by the host daemon while the job's
# checkout lives on a docker volume that is not a host path -- the mount
# silently succeeds and /work is empty. Baking the build in is also the
# only way this suite can have both a built extension and the
# `--network none` it runs under, since a container with no network cannot
# install anything.
#
# All three external artifacts are pinned by digest, and are fetched in
# layers above the repo copy, so editing the harness or any source file
# re-runs only the two cheap layers at the bottom. The Firefox version in
# particular must not float: -remote-allow-system-access is mandatory on
# 153 and was not on 142, so the flag the harness passes is
# version-coupled.
# node:22-bookworm-slim, 2026-08-12 # node:22-bookworm-slim, 2026-08-12
FROM node@sha256:d649c27dae7ba0137b3cef5dd75baa422c08dc3d9e3fc0c23dfb172dc3cc6436 FROM node@sha256:d649c27dae7ba0137b3cef5dd75baa422c08dc3d9e3fc0c23dfb172dc3cc6436
@@ -48,4 +62,16 @@ ENV FIREFOX_BIN=/opt/firefox/firefox
ENV GECKODRIVER=/usr/local/bin/geckodriver ENV GECKODRIVER=/usr/local/bin/geckodriver
WORKDIR /work WORKDIR /work
# Same layering as the root Dockerfile: script/bootstrap installs the
# prerequisites and the dependencies, and the manifests are copied first so
# that layer is cached until they change.
COPY script/ script/
COPY package.json yarn.lock ./
RUN script/bootstrap
COPY . .
RUN make build
CMD ["node", "tests/e2e/firefox/run.js", "dist/firefox"] CMD ["node", "tests/e2e/firefox/run.js", "dist/firefox"]

238
tests/e2e/firefox/dapp.js Normal file
View File

@@ -0,0 +1,238 @@
// A loopback dApp origin and stub Ethereum node for the Firefox suite.
//
// The Firefox container runs with --network none, and the harness note in
// driver.js records the consequence: with no http:// origin in reach, no
// content script was ever injected, so content-script behaviour was
// UNVERIFIED and the dApp flows could not be driven at all.
//
// --network none removes every interface except loopback, and loopback is
// enough. This serves the page and the JSON-RPC endpoint from 127.0.0.1
// inside the same container Firefox runs in, so the dApp round trips execute
// against a real http:// origin and the run stays as offline as it was: the
// only reachable peer is this process.
//
// The page itself is not written twice. DAPP_HTML comes from the Chrome
// suite's fixture, so both harnesses drive the same __dapp API and the same
// message log.
//
// Unlike driver.js this file does use ethers, and it has to: the node has to
// answer eth_sendRawTransaction with the hash ethers computes for the
// artifact it was handed, or provider.broadcastTransaction() refuses the
// answer, and the suite recovers signatures itself rather than believing the
// extension's own verdict.
"use strict";
const http = require("http");
const { Transaction } = require("ethers");
const { DAPP_HTML } = require("../network");
// The same fee shape the Chrome suite uses, for the same reason: it has to
// pass the ceilings in src/shared/approvalVerify.js and it has to leave the
// reserve and the estimate distinguishable.
const GAS_LIMIT = 21000n;
const BASE_FEE_WEI = 100000000000n; // 100 gwei
const PRIORITY_FEE_WEI = 1000000000n; // 1 gwei
const GAS_PRICE_WEI = BASE_FEE_WEI + PRIORITY_FEE_WEI;
const STUB_BLOCK_NUMBER = 21000000;
// A 32-byte zero word, returned for every eth_call. It is what makes ethers'
// ENS reverse lookup resolve to "no resolver set" instead of throwing, and a
// throw there reaches the console through src/shared/log.js, which fails the
// run on its own.
const ZERO_WORD = "0x" + "0".repeat(64);
// One ETH, so the popup's balance lines render something and the wallet does
// not look empty on the approval screen.
const STUB_BALANCE_WEI = 10n ** 18n;
function hex(value) {
return "0x" + BigInt(value).toString(16);
}
function latestBlock() {
return {
hash: "0x" + "11".repeat(32),
parentHash: "0x" + "22".repeat(32),
number: hex(STUB_BLOCK_NUMBER),
timestamp: hex(1767326645),
nonce: "0x0000000000000000",
difficulty: "0x0",
gasLimit: "0x1c9c380",
gasUsed: "0xf4240",
miner: "0xc0ffee0000000000000000000000000000c0ffee",
extraData: "0x",
baseFeePerGas: hex(BASE_FEE_WEI),
transactions: [],
};
}
const RPC_RESULTS = {
eth_chainId: "0x1",
net_version: "1",
eth_blockNumber: hex(STUB_BLOCK_NUMBER),
eth_getBalance: hex(STUB_BALANCE_WEI),
eth_call: ZERO_WORD,
eth_getCode: "0x",
eth_gasPrice: hex(GAS_PRICE_WEI),
eth_estimateGas: hex(GAS_LIMIT),
eth_getTransactionCount: "0x0",
eth_maxPriorityFeePerGas: hex(PRIORITY_FEE_WEI),
// "accepted but not mined", which is what a node says about a transaction
// it has only just taken. The wait screen the approval hands off to polls
// this for the rest of the run.
eth_getTransactionReceipt: null,
web3_clientVersion: "autistmask-e2e-firefox/0",
};
// Answer one JSON-RPC call. `broadcast` collects every raw transaction that
// reached this node, which is what the transaction assertions are made
// against — the artifact as the node saw it, never as the extension described
// it.
function rpcResult(req, state) {
const method = req.method;
if (method === "eth_sendRawTransaction") {
const raw = req.params && req.params[0];
state.broadcast.push(raw);
// ethers checks the hash it is given against the hash it computes for
// the artifact it sent, so this cannot be a fixed string.
return Transaction.from(raw).hash;
}
if (method === "eth_getBlockByNumber" || method === "eth_getBlockByHash") {
return latestBlock();
}
if (Object.prototype.hasOwnProperty.call(RPC_RESULTS, method)) {
return RPC_RESULTS[method];
}
// Never a silent default. An unstubbed method answered with null looks
// like a working node returning nothing, and the assertion downstream
// fails somewhere unrelated.
state.unstubbed.push(method);
throw new Error("no fixture for JSON-RPC method " + method);
}
function readBody(req) {
return new Promise((resolve, reject) => {
let body = "";
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => resolve(body));
req.on("error", reject);
});
}
function handleRpcBody(body, state) {
const parsed = JSON.parse(body);
const answer = (req) => {
try {
return {
jsonrpc: "2.0",
id: req.id,
result: rpcResult(req, state),
};
} catch (e) {
return {
jsonrpc: "2.0",
id: req.id,
error: { code: -32601, message: e.message },
};
}
};
return Array.isArray(parsed) ? parsed.map(answer) : answer(parsed);
}
/**
* Serve the dApp page and the stub node on loopback.
*
* @returns {Promise<Object>} the running fixture: `url` and `origin` of the
* page, `rpcUrl` for the extension's rpcUrl setting, `broadcast` (the raw
* transactions the node received, in order), `unstubbed` (JSON-RPC methods
* nothing answered) and `close()`.
*/
async function startDappServer() {
const state = { broadcast: [], unstubbed: [], requests: [] };
const server = http.createServer((req, res) => {
const url = new URL(req.url, "http://127.0.0.1");
state.requests.push(req.method + " " + url.pathname);
if (url.pathname === "/rpc" && req.method === "POST") {
readBody(req)
.then((body) => {
const payload = JSON.stringify(handleRpcBody(body, state));
res.writeHead(200, {
"Content-Type": "application/json",
// The extension fetches this from its background
// page, whose origin is moz-extension://. Without CORS
// the fetch fails and every transaction assertion
// fails for a reason that has nothing to do with the
// wallet.
"Access-Control-Allow-Origin": "*",
});
res.end(payload);
})
.catch((e) => {
res.writeHead(500, { "Content-Type": "text/plain" });
res.end(String(e && e.message));
});
return;
}
if (url.pathname === "/") {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(DAPP_HTML);
return;
}
// An empty favicon rather than a 404: a 404 is a page error in
// Firefox's console under some settings, and the suite fails the run
// on those.
if (url.pathname === "/favicon.ico") {
res.writeHead(200, { "Content-Type": "image/x-icon" });
res.end("");
return;
}
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("not found");
});
await new Promise((resolve, reject) => {
server.on("error", reject);
// Port 0: this host runs many sessions at once, and a fixed port is a
// guaranteed collision rather than a possible one.
server.listen(0, "127.0.0.1", resolve);
});
const { port } = server.address();
const origin = "http://127.0.0.1:" + port;
return {
origin,
url: origin + "/",
rpcUrl: origin + "/rpc",
broadcast: state.broadcast,
unstubbed: state.unstubbed,
requests: state.requests,
close: () =>
new Promise((resolve) => {
server.closeAllConnections();
server.close(() => resolve());
}),
};
}
module.exports = {
GAS_LIMIT,
GAS_PRICE_WEI,
STUB_BALANCE_WEI,
startDappServer,
};

View File

@@ -88,7 +88,7 @@ class Driver {
let parsed; let parsed;
try { try {
parsed = JSON.parse(text); parsed = JSON.parse(text);
} catch (_) { } catch {
throw new Error( throw new Error(
method + " " + path + ": non-JSON response: " + text, method + " " + path + ": non-JSON response: " + text,
); );
@@ -110,6 +110,26 @@ class Driver {
"extensions.webextensions.uuids": JSON.stringify({ "extensions.webextensions.uuids": JSON.stringify({
[EXTENSION_ID]: EXTENSION_UUID, [EXTENSION_ID]: EXTENSION_UUID,
}), }),
// The container has loopback and nothing else. Firefox's own
// link-status detection can read that as "offline" and then
// refuse every request, including the ones to the loopback dApp
// origin the suite serves; this takes the decision away from it.
"network.manage-offline-status": false,
// Force the site-connection prompt down its windows.create()
// fallback.
//
// src/background/index.js prefers the toolbar-anchored popup for
// that one approval and opens a real window only when
// openPopup() refuses. A panel is not a top-level browsing
// context, so WebDriver cannot see it, list it or click in it —
// the same blind spot the Chrome harness documents. Leaving this
// at its default would make which path runs depend on whether a
// headless Firefox counts as having had a user gesture, which is
// not a thing to leave to chance in a suite that has to be able
// to fail. The window path is shipped code and the same approval
// id, so what is driven is real; what is NOT covered either way
// is the panel presentation itself.
"extensions.openPopupWithoutUserGesture.enabled": false,
}; };
const value = await this.send("POST", "/session", { const value = await this.send("POST", "/session", {
@@ -179,6 +199,16 @@ class Driver {
return this.session("POST", "/execute/sync", { script, args }); return this.session("POST", "/execute/sync", { script, args });
} }
// The asynchronous form: the script is handed a resolve callback as its
// last argument and the call settles when that is invoked. Everything
// interesting about an extension page is promise-shaped — storage reads,
// the provider's own request() — and /execute/sync cannot wait for any
// of it.
async executeAsync(script, args = []) {
await this.setContext("content");
return this.session("POST", "/execute/async", { script, args });
}
// Runs in the privileged chrome scope, where Services and Ci exist. // Runs in the privileged chrome scope, where Services and Ci exist.
async executeChrome(script, args = []) { async executeChrome(script, args = []) {
await this.setContext("chrome"); await this.setContext("chrome");
@@ -199,7 +229,9 @@ class Driver {
// condition it was waiting on rather than "timed out". // condition it was waiting on rather than "timed out".
async waitFor(what, script, args = [], timeout = DEFAULT_WAIT_MS) { async waitFor(what, script, args = [], timeout = DEFAULT_WAIT_MS) {
const deadline = Date.now() + timeout; const deadline = Date.now() + timeout;
let last = null; // Assigned on every path through the loop body before it is read, so
// there is no initializer to give it.
let last;
for (;;) { for (;;) {
try { try {
const v = await this.execute(script, args); const v = await this.execute(script, args);
@@ -329,6 +361,63 @@ class Driver {
[selector], [selector],
); );
} }
// ------------------------------------------------------------ windows
//
// The approval prompts this suite drives are separate top-level windows
// the extension opens itself, so every one of them is a window handle
// here and the suite has to move between them explicitly.
async windowHandles() {
return this.session("GET", "/window/handles");
}
async currentWindow() {
return this.session("GET", "/window");
}
async switchToWindow(handle) {
await this.setContext("content");
await this.session("POST", "/window", { handle });
}
async newWindow(type = "window") {
await this.setContext("content");
const value = await this.session("POST", "/window/new", { type });
return value.handle;
}
// Closes the current window and leaves the session on `fallback`, because
// a session whose current window is gone fails every subsequent command
// with "no such window" rather than with anything diagnosable.
async closeWindow(fallback) {
await this.setContext("content");
await this.session("DELETE", "/window");
if (fallback) await this.switchToWindow(fallback);
}
async url() {
return this.session("GET", "/url");
}
// The handle of the first window whose URL matches, or null. Restores the
// window that was current before the search either way: a probe that
// silently relocates the session is a trap for the step after it.
async findWindow(predicate) {
const origin = await this.currentWindow();
try {
for (const handle of await this.windowHandles()) {
await this.switchToWindow(handle);
if (predicate(await this.url())) return handle;
}
return null;
} finally {
// Tolerated: the window the search started from may have been the
// one that just closed, and a throw in here would replace the
// real result with "no such window".
await this.switchToWindow(origin).catch(() => {});
}
}
} }
// ------------------------------------------------------- error capture // ------------------------------------------------------- error capture
@@ -349,10 +438,15 @@ class Driver {
// background page, which BiDi would not have covered even if it worked. // background page, which BiDi would not have covered even if it worked.
// Background-page capture is verified by probe — a throw at the top of // Background-page capture is verified by probe — a throw at the top of
// src/background/index.js, which kills the background page outright, fails // src/background/index.js, which kills the background page outright, fails
// the run. Content-script errors should arrive by the same route, but that // the run.
// is UNVERIFIED here and must not be claimed: the container runs with //
// --network none, so there is no http:// page for a content script to be // Content scripts ARE now exercised: tests/e2e/firefox/dapp.js serves a page
// injected into and this suite never exercises one. // from loopback, which survives --network none, and the suite drives the
// EIP-1193 round trips through the content script injected into it. What is
// still unproven is the CAPTURE, not the execution — no probe has forced a
// throw from inside a content script and watched it fail the run, so an
// uncaught content-script error arriving by this route remains an
// expectation rather than a demonstrated fact. Do not claim otherwise.
// //
// Warnings are excluded so the semantics match Playwright's pageerror: // Warnings are excluded so the semantics match Playwright's pageerror:
// uncaught errors only. // uncaught errors only.
@@ -420,7 +514,7 @@ async function waitForDriverReady(base, timeoutMs) {
const body = await res.json(); const body = await res.json();
if (body && body.value && body.value.ready !== false) return; if (body && body.value && body.value.ready !== false) return;
} }
} catch (_) { } catch {
// not listening yet // not listening yet
} }
if (Date.now() >= deadline) { if (Date.now() >= deadline) {

View File

@@ -15,8 +15,15 @@
// UI steps below are written twice on purpose. Chrome runs on Playwright, // UI steps below are written twice on purpose. Chrome runs on Playwright,
// which cannot see extension-page errors in Firefox at all (see the BiDi // which cannot see extension-page errors in Firefox at all (see the BiDi
// note in driver.js), so the two backends have no common substrate to // note in driver.js), so the two backends have no common substrate to
// abstract over. Three duplicated steps do not pay for a shim; revisit if // abstract over. Duplicated steps do not pay for a shim; revisit if this
// this suite grows to where they do. // suite grows to where they do. What IS shared is the dApp page fixture
// itself — DAPP_HTML, served here from loopback by dapp.js — so an assertion
// about the __dapp API means the same thing on both browsers.
//
// The dApp steps need an http:// origin, which --network none was thought to
// rule out. It does not: loopback survives it, so the page and the stub node
// are served from 127.0.0.1 inside the container and the run reaches nothing
// but this process. See tests/e2e/firefox/dapp.js.
// //
// LIMITATION, and the difference from the Chrome suite worth knowing: error // LIMITATION, and the difference from the Chrome suite worth knowing: error
// capture here is POLL-BASED, not event-streamed. The console service is // capture here is POLL-BASED, not event-streamed. The console service is
@@ -40,7 +47,21 @@
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const {
Transaction,
formatEther,
getAddress,
getBytes,
hexlify,
parseEther,
toQuantity,
toUtf8Bytes,
verifyMessage,
} = require("ethers");
const { ConsoleErrors, EXTENSION_ORIGIN, start, sleep } = require("./driver"); const { ConsoleErrors, EXTENSION_ORIGIN, start, sleep } = require("./driver");
const { startDappServer } = require("./dapp");
const { STUB_COUNTERPARTY } = require("../network");
const REPO_ROOT = path.resolve(__dirname, "..", "..", ".."); const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
const POPUP_URL = EXTENSION_ORIGIN + "/src/popup/index.html"; const POPUP_URL = EXTENSION_ORIGIN + "/src/popup/index.html";
@@ -137,8 +158,598 @@ step("add token screen opens from address detail", async (env) => {
assert(picks > 0, "no common-token quick-pick buttons rendered"); assert(picks > 0, "no common-token quick-pick buttons rendered");
}); });
// ------------------------------------------------- the dApp round trips
//
// Everything above drives the popup on its own. From here the page, the
// content script, the inpage provider, the background page and the approval
// window all have to work together — the paths
// https://git.eeqj.de/sneak/AutistMask/issues/153 rewrote, and the ones no
// Firefox test reached before. They are asserted here because nothing else
// covers them on this browser, not because they were broken: these steps
// pass against the pre-refactor callback code too, which is how the issue's
// premise was refuted.
//
// The shape is the Chrome suite's (tests/e2e/run.js, the #183 section) and
// the assertions mean the same things:
//
// - the signature is recovered here, in the runner, from the artifact the
// extension produced, and compared against the address read out of
// extension storage. The background verifies too; these assertions do not
// lean on that, because a test that trusted the wallet's own verdict would
// pass against a wallet that verified nothing.
// - the transaction is asserted against the raw signed transaction that
// reached the stub node, not against anything the extension reported.
//
// What this does NOT cover: a real dApp with real funds against a real
// network. The node is a fixture on loopback.
const SIGN_TEXT = "AutistMask e2e round trip: personal_sign";
const SIGN_HEX = hexlify(toUtf8Bytes(SIGN_TEXT));
const TX_VALUE_ETH = "0.0123";
const TX_VALUE_WEI = parseEther(TX_VALUE_ETH);
// Call data that decodes as nothing, so the screen assertion compares the
// calldata itself rather than a decoder's summary of it.
const TX_DATA = "0xdeadbeef" + "01".repeat(28);
const USER_REJECTION_MESSAGE = "User rejected the request.";
// Read the extension's persisted state, point its rpcUrl at the loopback stub
// node, and hand back the active address. Runs on the popup page, which is
// the one moz-extension:// document the suite has open and therefore the only
// place the storage API is reachable from.
async function pointAtStubNode(d, rpcUrl) {
const outcome = await d.executeAsync(
`const done = arguments[arguments.length - 1];
const rpcUrl = arguments[0];
const api = typeof browser !== "undefined" ? browser : chrome;
Promise.resolve(api.storage.local.get("autistmask"))
.then((r) => {
const s = r.autistmask;
if (!s) throw new Error("the extension has no persisted state");
s.rpcUrl = rpcUrl;
const w = s.wallets && s.wallets[0];
const first = w && w.addresses && w.addresses[0];
const address = s.activeAddress || (first && first.address);
if (!address) throw new Error("the extension holds no address");
return Promise.resolve(api.storage.local.set({ autistmask: s }))
.then(() => done({ address: address }));
})
.catch((e) => done({ error: String((e && e.message) || e) }));`,
[rpcUrl],
);
assert(
outcome && !outcome.error,
"could not point the extension at the stub node: " +
(outcome && outcome.error),
);
return getAddress(outcome.address);
}
// The approval window the background opened. Approvals are raised from an RPC
// call rather than from a user gesture, so the extension opens a real window
// for them, which is an ordinary window handle here.
async function waitForApprovalWindow(d, timeout = 30000) {
const deadline = Date.now() + timeout;
for (;;) {
const handle = await d.findWindow((u) => u.includes("?approval="));
if (handle) return handle;
if (Date.now() > deadline) {
throw new Error(
"the extension opened no approval window within " +
timeout +
"ms",
);
}
await sleep(100);
}
}
function startRequest(d, key, method, params) {
return d.execute(
"window.__dapp.start(arguments[0], arguments[1], arguments[2]);" +
" return true;",
[key, method, params],
);
}
// The settled outcome of a parked request, or {settled:"pending"} if it is
// still outstanding. A bounded wait rather than a bare await: "returns a
// rejection rather than hanging" is one of the things under test, and an
// await would report a hang as a step timeout with no indication of which
// call never settled.
function settleRequest(d, key, timeout = 45000) {
return d.executeAsync(
`const done = arguments[arguments.length - 1];
const key = arguments[0];
const timeout = arguments[1];
Promise.race([
window.__dapp.settle(key),
new Promise((r) => setTimeout(() => r({ settled: "pending" }), timeout)),
]).then(done, (e) => done({ settled: "error", message: String(e) }));`,
[key, timeout],
);
}
// Every AUTISTMASK_* message that has crossed between the page and the
// content script. This is the boundary half of the rejection assertion: the
// code has to be on the wire as well as on the Error the page catches, so a
// pass cannot come from the provider inventing one.
function dappMessages(d, type) {
return d.execute(
// `want` is bound outside the callback deliberately: inside it,
// arguments[0] is the message being tested, not the script argument,
// and the filter silently matches nothing.
"var want = arguments[0];" +
" return window.__dapp.messages.filter(function (m) {" +
" return !want || m.type === want; });",
[type || null],
);
}
async function lastResponseError(d) {
const responses = await dappMessages(d, "AUTISTMASK_RESPONSE");
const last = responses[responses.length - 1];
assert(last, "the page received no AUTISTMASK_RESPONSE at all");
return last.error || null;
}
// A rejected prompt, asserted at both ends: the page's promise rejected
// rather than hanging or resolving, and the response that crossed the
// boundary carried EIP-1193 code 4001.
async function assertUserRejection(d, key, label) {
const outcome = await settleRequest(d, key);
assert(
outcome.settled !== "pending",
label + " never settled: the rejected prompt left the page hanging",
);
assert(
outcome.settled === "rejected",
label + " resolved instead of rejecting: " + JSON.stringify(outcome),
);
assert(
outcome.message === USER_REJECTION_MESSAGE,
label + " rejected with the wrong message: " + outcome.message,
);
const error = await lastResponseError(d);
assert(
error && error.code === 4001,
label +
" did not carry EIP-1193 code 4001 across the boundary: " +
JSON.stringify(error),
);
assert(
outcome.hasCode,
label +
" reached the page as an error with no code property at all, so a " +
"dApp cannot tell the user's refusal from a failure: " +
JSON.stringify(outcome),
);
assert(
outcome.code === 4001,
label +
" reached the page with code " +
JSON.stringify(outcome.code) +
" rather than EIP-1193 4001",
);
assert(
outcome.name === "ProviderRpcError",
label +
" reached the page as " +
JSON.stringify(outcome.name) +
" rather than an EIP-1193 ProviderRpcError",
);
console.log(
"# " +
label +
": code 4001 on the wire and on the page's " +
outcome.name,
);
}
step("the loopback dApp page gets the real inpage provider", async (env) => {
const d = env.driver;
// The popup is still the current window; point the extension at the stub
// node from there, then reload it so its in-memory copy of the state
// carries the new rpcUrl and cannot save the old one back over it.
env.address = await pointAtStubNode(d, env.server.rpcUrl);
await d.navigate(POPUP_URL);
await d.waitVisible("#view-main", STEP_TIMEOUT_MS);
env.popupWindow = await d.currentWindow();
env.dappWindow = await d.newWindow("tab");
await d.switchToWindow(env.dappWindow);
await d.navigate(env.server.url);
// window.ethereum is not the fixture's doing — it is the shipped content
// script, injected into a real http:// origin. Waiting for it is waiting
// for the real provider to have installed itself.
await d.waitFor(
"the injected EIP-1193 provider and the test page API",
"return !!window.ethereum && !!window.__dapp;",
[],
STEP_TIMEOUT_MS,
);
// EIP-6963, asked of the provider itself. The announcement carries the
// uuid src/content/index.js reads out of extension storage — call site 1
// in the issue — and it has to name this extension and hand back the very
// object on window.ethereum.
const announced = await d.executeAsync(
`const done = arguments[arguments.length - 1];
const onAnnounce = (e) => {
window.removeEventListener("eip6963:announceProvider", onAnnounce);
done({
rdns: e.detail.info.rdns,
uuid: e.detail.info.uuid,
isWindowEthereum: e.detail.provider === window.ethereum,
});
};
window.addEventListener("eip6963:announceProvider", onAnnounce);
window.dispatchEvent(new Event("eip6963:requestProvider"));
setTimeout(() => done(null), 15000);`,
);
assert(announced, "the provider announced itself to no EIP-6963 request");
assert(
announced.rdns === "berlin.sneak.autistmask",
"the announced provider is not this extension: " +
JSON.stringify(announced),
);
assert(
announced.isWindowEthereum,
"the announced provider is not the object on window.ethereum",
);
assert(
typeof announced.uuid === "string" && announced.uuid.length === 36,
"the announcement carries no stored provider uuid: " +
JSON.stringify(announced.uuid),
);
// A full page -> content script -> background round trip that needs no
// approval, so the relay is proven before any prompt is driven. This is
// call site 2, the one that used to fail for every window.ethereum
// request a dApp made.
const chainId = await d.executeAsync(
`const done = arguments[arguments.length - 1];
window.ethereum.request({ method: "eth_chainId" }).then(
(r) => done({ ok: r }),
(e) => done({ err: String((e && e.message) || e) }),
);`,
);
assert(
chainId && chainId.ok === "0x1",
"eth_chainId did not round trip through the extension: " +
JSON.stringify(chainId),
);
console.log(
"# dapp origin " + env.server.origin + " active address " + env.address,
);
});
step(
"eth_requestAccounts approved returns the selected address",
async (env) => {
const d = env.driver;
await d.switchToWindow(env.dappWindow);
await startRequest(d, "accounts", "eth_requestAccounts", []);
const popup = await waitForApprovalWindow(d);
await d.switchToWindow(popup);
await d.waitVisible("#view-approve-site");
const hostname = await d.text("#approve-hostname");
assert(
hostname === "127.0.0.1",
"the site prompt names the wrong origin: " +
JSON.stringify(hostname),
);
const shown = await d.text("#approve-address");
assert(
shown.toLowerCase().includes(env.address.toLowerCase()),
"the site prompt shows the wrong address: " + JSON.stringify(shown),
);
// Remembered, so the origin stays authorized for the sign and transaction
// steps below.
const checked = await d.execute(
'return document.getElementById("approve-remember").checked;',
);
if (!checked) await d.click("#approve-remember");
await d.click("#btn-approve");
// The approve button closes its own window, so get off it before asking
// the page anything.
await d.switchToWindow(env.dappWindow);
const outcome = await settleRequest(d, "accounts");
assert(
outcome.settled === "resolved",
"eth_requestAccounts did not resolve: " + JSON.stringify(outcome),
);
assert(
Array.isArray(outcome.result) && outcome.result.length === 1,
"eth_requestAccounts returned no single account: " +
JSON.stringify(outcome.result),
);
assert(
getAddress(outcome.result[0]) === env.address,
"eth_requestAccounts returned " +
outcome.result[0] +
", not the selected address " +
env.address,
);
},
);
step(
"personal_sign returns a signature that recovers to the address",
async (env) => {
const d = env.driver;
await d.switchToWindow(env.dappWindow);
await startRequest(d, "sign", "personal_sign", [SIGN_HEX, env.address]);
const popup = await waitForApprovalWindow(d);
await d.switchToWindow(popup);
await d.waitVisible("#view-approve-sign");
const screen = await d.execute(
`return {
hostname: document.getElementById("approve-sign-hostname").textContent,
type: document.getElementById("approve-sign-type").textContent,
message: document.getElementById("approve-sign-message").textContent,
from: document.getElementById("approve-sign-from").textContent,
};`,
);
assert(
screen.hostname === "127.0.0.1",
"the sign prompt names the wrong origin: " +
JSON.stringify(screen.hostname),
);
assert(
screen.type === "Personal message",
"the sign prompt reports the wrong type: " +
JSON.stringify(screen.type),
);
assert(
screen.message === SIGN_TEXT,
"the sign prompt shows the wrong message: " +
JSON.stringify(screen.message),
);
assert(
screen.from.toLowerCase().includes(env.address.toLowerCase()),
"the sign prompt shows the wrong signing address: " +
JSON.stringify(screen.from),
);
await d.fill("#approve-sign-password", PASSWORD);
await d.click("#btn-approve-sign");
await d.switchToWindow(env.dappWindow);
const outcome = await settleRequest(d, "sign");
assert(
outcome.settled === "resolved",
"personal_sign did not resolve: " + JSON.stringify(outcome),
);
const recovered = getAddress(
verifyMessage(getBytes(SIGN_HEX), outcome.result),
);
console.log(
"# personal_sign: recovered=" +
recovered +
" expected=" +
env.address,
);
assert(
recovered === env.address,
"the personal_sign signature recovers to " +
recovered +
", not to the approved address " +
env.address,
);
},
);
step(
"eth_sendTransaction shows the transaction and returns its hash",
async (env) => {
const d = env.driver;
const before = env.server.broadcast.length;
await d.switchToWindow(env.dappWindow);
await startRequest(d, "tx", "eth_sendTransaction", [
{
from: env.address,
to: STUB_COUNTERPARTY,
value: toQuantity(TX_VALUE_WEI),
data: TX_DATA,
},
]);
const popup = await waitForApprovalWindow(d);
await d.switchToWindow(popup);
await d.waitVisible("#view-approve-tx");
const screen = await d.execute(
`return {
hostname: document.getElementById("approve-tx-hostname").textContent,
from: document.getElementById("approve-tx-from").textContent,
to: document.getElementById("approve-tx-to").textContent,
value: document.getElementById("approve-tx-value").textContent,
data: document.getElementById("approve-tx-data").textContent,
dataShown: !document
.getElementById("approve-tx-data-section")
.classList.contains("hidden"),
};`,
);
assert(
screen.hostname === "127.0.0.1",
"the transaction prompt names the wrong origin: " +
JSON.stringify(screen.hostname),
);
assert(
screen.from.toLowerCase().includes(env.address.toLowerCase()),
"the transaction prompt shows the wrong sender: " +
JSON.stringify(screen.from),
);
assert(
screen.to.toLowerCase().includes(STUB_COUNTERPARTY.toLowerCase()),
"the transaction prompt shows the wrong recipient: " +
JSON.stringify(screen.to),
);
assert(
screen.value.startsWith(TX_VALUE_ETH + " ETH"),
"the transaction prompt shows the wrong value: " +
JSON.stringify(screen.value),
);
assert(
screen.dataShown && screen.data === TX_DATA,
"the transaction prompt does not show the approved call data: " +
JSON.stringify(screen.data),
);
await d.fill("#approve-tx-password", PASSWORD);
await d.click("#btn-approve-tx");
// The approval window hands off to the wait screen rather than closing,
// and the hash it shows is asserted before it is retired: left open it
// polls the stub node for a receipt for the rest of the run.
await d.waitVisible("#view-wait-tx", STEP_TIMEOUT_MS);
const waitHash = await d.text("#wait-tx-hash");
await d.switchToWindow(env.dappWindow);
const outcome = await settleRequest(d, "tx");
assert(
outcome.settled === "resolved",
"eth_sendTransaction did not resolve: " + JSON.stringify(outcome),
);
// The artifact as the node saw it, not as the extension described it.
assert(
env.server.broadcast.length === before + 1,
"expected exactly one raw transaction to reach the node, got " +
(env.server.broadcast.length - before),
);
const signed = Transaction.from(
env.server.broadcast[env.server.broadcast.length - 1],
);
console.log(
"# eth_sendTransaction: signer=" +
getAddress(signed.from) +
" to=" +
getAddress(signed.to) +
" value=" +
formatEther(signed.value) +
" chainId=" +
signed.chainId,
);
assert(
getAddress(signed.from) === env.address,
"the broadcast transaction was signed by " +
getAddress(signed.from) +
", not by the approved address " +
env.address,
);
assert(
getAddress(signed.to) === getAddress(STUB_COUNTERPARTY),
"the broadcast transaction goes to " + signed.to,
);
assert(
signed.value === TX_VALUE_WEI,
"the broadcast transaction carries " +
formatEther(signed.value) +
" ETH, not the approved " +
TX_VALUE_ETH,
);
assert(
signed.data === TX_DATA,
"the broadcast transaction carries different call data: " +
signed.data,
);
assert(
signed.chainId === 1n,
"the broadcast transaction is for chain " + signed.chainId,
);
assert(
outcome.result === signed.hash,
"the page received " +
outcome.result +
", not the hash of the broadcast transaction " +
signed.hash,
);
assert(
waitHash.includes(signed.hash),
"the wait screen shows a different hash: " +
JSON.stringify(waitHash),
);
await d.switchToWindow(popup);
await d.closeWindow(env.dappWindow);
},
);
step(
"closing an approval window rejects the request with 4001",
async (env) => {
const d = env.driver;
const before = env.server.broadcast.length;
await d.switchToWindow(env.dappWindow);
await startRequest(d, "sign-closed", "personal_sign", [
SIGN_HEX,
env.address,
]);
const popup = await waitForApprovalWindow(d);
await d.switchToWindow(popup);
await d.waitVisible("#view-approve-sign");
// Closed, not rejected: this is the windows.onRemoved path, which can
// only fire if windows.create() handed back a window id for the
// approval to be matched against — call site 4 in the issue, and the
// reason suppressing that write-back turns this step red.
await d.closeWindow(env.dappWindow);
await assertUserRejection(d, "sign-closed", "a closed approval window");
assert(
env.server.broadcast.length === before,
"a closed approval window still put a transaction on the node",
);
},
);
// ------------------------------------------------------------- runner // ------------------------------------------------------------- runner
// Uncaught extension errors that are known, tracked and deliberately
// tolerated, in the same spirit as ALLOWED_ERRORS in tests/e2e/harness.js:
// every entry names the issue that will delete it, and every occurrence is
// still printed, so tolerating one is visible in the log rather than silent.
// This is the only concession in an otherwise zero-tolerance policy.
const ALLOWED_ERRORS = [
{
// The site-connection buttons in src/popup/views/approval.js send
// their decision and call window.close() on the next line. Firefox's
// BaseContext.wrapPromise reports, through Cu.reportError, any
// extension-API promise that settles after its context unloaded —
// whether or not the caller attached a handler, so notify()'s catch
// cannot suppress it.
//
// Pre-existing, and not introduced by the promise shim: the send was
// already unawaited, and this suite is merely the first thing to
// drive that window on Firefox. It is the same teardown ordering as
// the issue below, whose fix — making the outcome independent of when
// the popup closes — removes this entry with it.
pattern: /Promise (?:resolved|rejected) after context unloaded/,
source: /\/src\/popup\/index\.js$/,
issue: "https://git.eeqj.de/sneak/AutistMask/issues/275",
},
];
function allowedFor(e) {
return ALLOWED_ERRORS.find(
(a) => a.pattern.test(e.msg) && a.source.test(e.src),
);
}
function formatError(e) { function formatError(e) {
return ( return (
e.msg + " (" + e.src + ":" + e.line + (e.cat ? ", " + e.cat : "") + ")" e.msg + " (" + e.src + ":" + e.line + (e.cat ? ", " + e.cat : "") + ")"
@@ -165,6 +776,21 @@ async function main() {
return; return;
} }
// Loopback survives --network none, so this is the http:// origin the
// dApp steps need and the node they talk to. Started before the browser
// so its url is available to the first step that asks for it.
let server;
try {
server = await startDappServer();
} catch (e) {
console.error(
"e2e-firefox: cannot serve the dApp fixture: " + e.message,
);
process.exitCode = 1;
return;
}
console.log("# dapp fixture: " + server.url + " rpc " + server.rpcUrl);
let driver; let driver;
try { try {
driver = await start(); driver = await start();
@@ -175,12 +801,20 @@ async function main() {
// absent suite. Never skip and report success. // absent suite. Never skip and report success.
console.error("e2e-firefox: cannot run the suite: " + e.message); console.error("e2e-firefox: cannot run the suite: " + e.message);
if (driver) await driver.quit().catch(() => {}); if (driver) await driver.quit().catch(() => {});
await server.close();
process.exitCode = 1; process.exitCode = 1;
return; return;
} }
const errors = new ConsoleErrors(driver, EXTENSION_ORIGIN); const errors = new ConsoleErrors(driver, EXTENSION_ORIGIN);
const env = { driver, phrase: null }; const env = {
driver,
server,
phrase: null,
address: null,
dappWindow: null,
popupWindow: null,
};
console.log("# extension origin: " + EXTENSION_ORIGIN); console.log("# extension origin: " + EXTENSION_ORIGIN);
console.log("1.." + steps.length); console.log("1.." + steps.length);
@@ -232,6 +866,20 @@ async function main() {
installFailure = null; installFailure = null;
} }
// Tolerated errors are set aside, never dropped: each one is
// printed with the issue that keeps it on the list, so the
// concession stays in the run output.
const tolerated = found.filter((e) => allowedFor(e));
found = found.filter((e) => !allowedFor(e));
for (const e of tolerated) {
console.log(
"# tolerated (" +
allowedFor(e).issue +
"): " +
formatError(e),
);
}
// Any uncaught error from an extension source fails the step // Any uncaught error from an extension source fails the step
// that provoked it, whether or not its assertions passed. // that provoked it, whether or not its assertions passed.
if (!failure && found.length > 0) { if (!failure && found.length > 0) {
@@ -256,7 +904,13 @@ async function main() {
// blamed on any one step, but they are still reported and they // blamed on any one step, but they are still reported and they
// still fail the run. // still fail the run.
await sleep(1000); await sleep(1000);
const trailing = await errors.take(); const trailingAll = await errors.take();
for (const e of trailingAll.filter((x) => allowedFor(x))) {
console.log(
"# tolerated (" + allowedFor(e).issue + "): " + formatError(e),
);
}
const trailing = trailingAll.filter((e) => !allowedFor(e));
console.log( console.log(
"# " + "# " +
(steps.length - failed) + (steps.length - failed) +
@@ -273,12 +927,25 @@ async function main() {
); );
for (const e of trailing) console.log("# " + formatError(e)); for (const e of trailing) console.log("# " + formatError(e));
} }
// A JSON-RPC method nothing answered means the extension asked the
// node something this fixture does not model, and whatever depended
// on the answer took the error branch instead. That is a hole in the
// fixture, not a pass.
if (server.unstubbed.length > 0) {
console.log(
"# FAILED: no fixture for JSON-RPC method(s) " +
[...new Set(server.unstubbed)].join(", "),
);
process.exitCode = 1;
}
if (failed > 0 || trailing.length > 0) { if (failed > 0 || trailing.length > 0) {
console.log("# FAILED"); console.log("# FAILED");
process.exitCode = 1; process.exitCode = 1;
} }
} finally { } finally {
await driver.quit().catch(() => {}); await driver.quit().catch(() => {});
await server.close();
} }
} }

View File

@@ -13,7 +13,7 @@ const os = require("os");
const path = require("path"); const path = require("path");
const { chromium } = require("playwright-core"); const { chromium } = require("playwright-core");
const { installNetworkStubs } = require("./network"); const { installNetworkStubs, WORKER_PROBE_URL } = require("./network");
const REPO_ROOT = path.resolve(__dirname, "..", ".."); const REPO_ROOT = path.resolve(__dirname, "..", "..");
const EXT_PATH = path.join(REPO_ROOT, "dist", "chrome"); const EXT_PATH = path.join(REPO_ROOT, "dist", "chrome");
@@ -129,42 +129,109 @@ function attachErrorListeners(ctx, errors) {
// if it ever stops being. // if it ever stops being.
} }
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// The most recently seen background worker, waiting for one if none has
// appeared yet. Most recent rather than first: Chrome stops an idle MV3
// worker and starts a fresh one on the next event, and a handle to a
// stopped worker cannot be evaluated in.
async function serviceWorker(ctx) { async function serviceWorker(ctx) {
const [existing] = ctx.serviceWorkers(); const workers = ctx.serviceWorkers();
if (existing) return existing; const latest = workers[workers.length - 1];
if (latest) return latest;
return ctx.waitForEvent("serviceworker", { timeout: 30000 }); return ctx.waitForEvent("serviceworker", { timeout: 30000 });
} }
// How long to wait for the background worker's first outbound request. // How long to wait for the probe request the worker is asked to make.
//
// 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; const WORKER_TRAFFIC_TIMEOUT_MS = 30000;
// ctx.route() only sees service-worker requests when Playwright runs with // ctx.route() only sees service-worker requests when Playwright runs with
// PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1, which script/test-e2e // PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1, which script/test-e2e
// sets. Without it the worker's traffic — notably the phishing blocklist // sets. Without it every fetch the background worker makes goes to the
// fetch src/background/index.js issues at startup — goes to the real // real internet and nothing says so. A harness whose isolation can lapse
// internet, and nothing says so, because src/shared/phishingDomains.js // in silence is worthless, so this does not take the flag on trust: a
// swallows fetch failures. A harness whose isolation can lapse in silence // request the worker itself issues has to show up in the route handler,
// is worthless, so this does not take the flag on trust: the background // or the suite refuses to run.
// 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(): // The anchor is a probe the harness asks the worker for, not traffic the
// evaluating in an extension worker this early kills it (the call fails // extension generates on its own. It used to be the phishing blocklist
// with "Target page, context or browser has been closed" and the worker // fetch src/background/index.js issued at startup; that fetch is gone —
// disappears), which would break the very thing being measured. Observing // the blocklist is vendored at build time and the extension contacts
// traffic the extension already generates costs nothing and cannot // nobody when it starts — so there is no longer any startup traffic to
// perturb it. // observe and the check generates its own.
async function assertWorkerTrafficIntercepted(stubs) { //
// Evaluating in the worker straight after launch does not work, and that
// is not a stale observation: it was tried again here and failed with
// "Target page, context or browser has been closed" on the first run.
// Chrome stops the freshly registered worker as soon as it has nothing to
// do, and the extension no longer gives it anything to do — which is the
// same change that removed the old anchor. So the probe wakes the worker
// before it evaluates in it, by sending it a message from an extension
// page and waiting for the reply: delivering a message is what starts a
// stopped worker, and a worker that has just answered one is alive.
// The evaluated fetch is not awaited, so nothing in the worker is held
// open by the probe either.
async function wakeWorker(ctx) {
const sw = await serviceWorker(ctx);
const extensionId = new URL(sw.url()).host;
const page = await ctx.newPage();
try {
await page.goto(
"chrome-extension://" + extensionId + "/src/popup/index.html",
);
// eth_chainId is answered from local state: it wakes the worker
// and changes nothing.
await page.evaluate(
() =>
new Promise((resolve) => {
chrome.runtime.sendMessage(
{
type: "AUTISTMASK_RPC",
method: "eth_chainId",
params: [],
},
() => resolve(null),
);
}),
);
} finally {
await page.close();
}
}
async function probeFromWorker(ctx, url) {
let lastError = null;
for (let attempt = 0; attempt < 5; attempt++) {
try {
await wakeWorker(ctx);
const sw = await serviceWorker(ctx);
await sw.evaluate((u) => {
// Deliberately not awaited and never rejected: what is
// being observed is that the request reaches the route
// handler, and an unhandled rejection in the worker would
// be collected as a suite error if it did not.
fetch(u).catch(() => {});
}, url);
return;
} catch (e) {
lastError = e;
await sleep(500);
}
}
throw new Error(
"could not ask the background worker to fetch " +
url +
", so service-worker interception was never tested. Last " +
"error: " +
(lastError && lastError.message),
);
}
async function assertWorkerTrafficIntercepted(ctx, stubs) {
await probeFromWorker(ctx, WORKER_PROBE_URL);
const seen = await stubs.waitForServiceWorkerTraffic( const seen = await stubs.waitForServiceWorkerTraffic(
WORKER_TRAFFIC_TIMEOUT_MS, WORKER_TRAFFIC_TIMEOUT_MS,
); );
@@ -177,19 +244,16 @@ async function assertWorkerTrafficIntercepted(stubs) {
throw new Error( throw new Error(
"observed no service-worker request in the route handler within " + "observed no service-worker request in the route handler within " +
WORKER_TRAFFIC_TIMEOUT_MS + WORKER_TRAFFIC_TIMEOUT_MS +
"ms. Under working interception the background worker's " + "ms, although the background worker was asked to fetch " +
"startup blocklist fetch (src/background/index.js) reaches the " + WORKER_PROBE_URL +
"handler about half a second after the route is installed. " + ". Two causes are plausible and this check cannot distinguish " +
"Two causes are plausible and this check cannot distinguish " +
"them: (1) service-worker interception is not in effect, so " + "them: (1) service-worker interception is not in effect, so " +
"that traffic went to the real internet unobserved — the suite " + "that request went to the real internet unobserved — the suite " +
"must be run through script/test-e2e, which sets " + "must be run through script/test-e2e, which sets " +
"PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1, and a " + "PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1, and a " +
"Playwright upgrade may have dropped or renamed that flag; " + "Playwright upgrade may have dropped or renamed that flag; " +
"(2) no worker request was made in the first place — the route " + "(2) the probe never ran, because the worker was torn down " +
"lost the startup race, or the worker no longer fetches at " + "between being handed over and being evaluated in. Either way " +
"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 fix is a replacement mechanism or an honest downgrade of " +
"the isolation claims in tests/e2e/network.js and README.md — " + "the isolation claims in tests/e2e/network.js and README.md — " +
"not deleting this check", "not deleting this check",
@@ -241,7 +305,7 @@ async function launch(routeOpts) {
routeOpts.report = (text) => errors.record("network", text); routeOpts.report = (text) => errors.record("network", text);
const stubs = await installNetworkStubs(ctx, routeOpts); const stubs = await installNetworkStubs(ctx, routeOpts);
await assertWorkerTrafficIntercepted(stubs); await assertWorkerTrafficIntercepted(ctx, stubs);
// The extension id is derived from the unpacked path, so it // The extension id is derived from the unpacked path, so it
// changes and must never be hardcoded. It is the host part of the // changes and must never be hardcoded. It is the host part of the
@@ -287,7 +351,7 @@ async function pageCompilesWasm(page) {
try { try {
await WebAssembly.compile(new Uint8Array(bytes)); await WebAssembly.compile(new Uint8Array(bytes));
return true; return true;
} catch (_) { } catch {
return false; return false;
} }
}, EMPTY_WASM_MODULE); }, EMPTY_WASM_MODULE);

View File

@@ -9,13 +9,12 @@
// //
// Service-worker coverage is not free: ctx.route() only sees worker // Service-worker coverage is not free: ctx.route() only sees worker
// traffic when PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1 is set in // traffic when PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1 is set in
// the environment, which script/test-e2e does. Without it the phishing // the environment, which script/test-e2e does. Without it every fetch the
// blocklist fetch that src/background/index.js issues at worker startup // MV3 background worker makes — the JSON-RPC calls behind every approval
// silently reaches raw.githubusercontent.com on the open internet, and // in this suite among them — goes to the real internet unobserved. That is
// src/shared/phishingDomains.js swallows the failure so nothing surfaces // not left to trust: waitForServiceWorkerTraffic() below backs the
// it. That is not left to trust: waitForServiceWorkerTraffic() below // launch-time canary in harness.js, which fails the entire suite if worker
// backs the launch-time canary in harness.js, which fails the entire // requests stop being visible here.
// suite if worker requests stop being visible here.
// //
// Anything not explicitly stubbed here is aborted AND reported to the // Anything not explicitly stubbed here is aborted AND reported to the
// error collector, so a newly added outbound call shows up as a test // error collector, so a newly added outbound call shows up as a test
@@ -93,9 +92,32 @@ function word(value) {
// is put there by the shipped manifest's MAIN-world content script, exactly // is put there by the shipped manifest's MAIN-world content script, exactly
// as it is on any http(s) page a user visits, so what these tests speak to // as it is on any http(s) page a user visits, so what these tests speak to
// is the real inpage provider and not a copy the harness wired up. // is the real inpage provider and not a copy the harness wired up.
//
// DAPP_HTML below is exported and served verbatim by the Firefox suite too
// (tests/e2e/firefox/dapp.js), from a loopback origin rather than through a
// route handler. The two suites drive different browsers over different
// protocols, but the page they drive — the __dapp API, the message log — is
// one fixture, so an assertion written against it means the same thing on
// both.
const DAPP_ORIGIN = "https://dapp.e2e.test"; const DAPP_ORIGIN = "https://dapp.e2e.test";
const DAPP_URL = DAPP_ORIGIN + "/"; const DAPP_URL = DAPP_ORIGIN + "/";
// The same page, served from a hostname that is on the vendored phishing
// blocklist, so the phishing warning can be driven end to end against the real
// list rather than a stub of it. It is a live entry at the pinned upstream
// commit; upstream prunes, so a re-vendoring run that retires it turns the
// phishing test red, and the fix is a current entry, not a weaker assertion.
const PHISHING_DAPP_ORIGIN = "https://myetheywallet.com";
const PHISHING_DAPP_URL = PHISHING_DAPP_ORIGIN + "/";
// A request the harness asks the background service worker to make, purely so
// that worker interception can be proved before any test runs. Nothing in the
// extension fetches at startup any more — the blocklist is vendored at build
// time — so the canary in harness.js has no product traffic to anchor on and
// generates its own. See assertWorkerTrafficIntercepted().
const WORKER_PROBE_ORIGIN = "https://worker-probe.e2e.test";
const WORKER_PROBE_URL = WORKER_PROBE_ORIGIN + "/canary";
// Requests are parked rather than awaited. An approval prompt only exists // Requests are parked rather than awaited. An approval prompt only exists
// while its call is in flight, so a test that awaited the promise could // while its call is in flight, so a test that awaited the promise could
// never drive the popup that has to settle it; start() files the promise // never drive the popup that has to settle it; start() files the promise
@@ -548,10 +570,9 @@ async function installNetworkStubs(ctx, opts) {
// E2E_TRACE_NETWORK=1 prints every request that reaches this handler, // E2E_TRACE_NETWORK=1 prints every request that reaches this handler,
// tagged [sw] when it originated in the background service worker. // tagged [sw] when it originated in the background service worker.
// It exists so the isolation claim above can be re-checked by anyone // It exists so the isolation claim above can be re-checked by anyone
// in one command, without editing files: the phishing blocklist fetch // in one command, without editing files: the canary probe and then
// showing up with an [sw] tag is the proof that the worker really is // every JSON-RPC call behind an approval showing up with an [sw] tag
// intercepted and that the raw.githubusercontent.com stub below is // is the proof that the worker really is intercepted.
// live code rather than decoration.
const trace = traceEnabled(process.env.E2E_TRACE_NETWORK); const trace = traceEnabled(process.env.E2E_TRACE_NETWORK);
// Regex rather than a glob so chrome-extension:// resource loads are // Regex rather than a glob so chrome-extension:// resource loads are
@@ -582,7 +603,11 @@ async function installNetworkStubs(ctx, opts) {
// trips run against a real http(s) origin — which is what makes the // trips run against a real http(s) origin — which is what makes the
// shipped content scripts inject at all — without any remote origin // shipped content scripts inject at all — without any remote origin
// being involved. // being involved.
if (url.origin === DAPP_ORIGIN && p === "/") { if (
(url.origin === DAPP_ORIGIN ||
url.origin === PHISHING_DAPP_ORIGIN) &&
p === "/"
) {
return route.fulfill({ return route.fulfill({
status: 200, status: 200,
contentType: "text/html; charset=utf-8", contentType: "text/html; charset=utf-8",
@@ -628,18 +653,10 @@ async function installNetworkStubs(ctx, opts) {
return jsonResponse(route, { Data: {} }); return jsonResponse(route, { Data: {} });
} }
// MetaMask phishing blocklist // The interception canary's own request. Answered with nothing: what
if ( // is being observed is that it arrived here at all.
url.hostname === "raw.githubusercontent.com" || if (url.href === WORKER_PROBE_URL) {
p.endsWith("/eth-phishing-detect/main/src/config.json") return route.fulfill({ status: 204, body: "" });
) {
return jsonResponse(route, {
version: 2,
tolerance: 2,
fuzzylist: [],
whitelist: [],
blacklist: [],
});
} }
// Best-effort Etherscan address labels: served as an empty page. // Best-effort Etherscan address labels: served as an empty page.
@@ -660,12 +677,12 @@ async function installNetworkStubs(ctx, opts) {
* Resolve with the first service-worker-originated request this * Resolve with the first service-worker-originated request this
* handler saw, or null if none arrives within `ms`. * handler saw, or null if none arrives within `ms`.
* *
* The background worker fetches the phishing blocklist at * The caller asks the worker for one request of its own (see
* startup, unconditionally, within about a second of the context * WORKER_PROBE_URL) and then waits here, so under working
* coming up — so under working interception this resolves almost * interception this resolves almost immediately. Nothing arriving
* immediately. Nothing arriving means worker traffic is bypassing * means worker traffic is bypassing the handler entirely and going
* the handler entirely and going to the real internet, which the * to the real internet, which the caller turns into a hard failure
* caller turns into a hard failure of the whole suite. * of the whole suite.
*/ */
waitForServiceWorkerTraffic(ms) { waitForServiceWorkerTraffic(ms) {
if (firstWorkerRequest) return Promise.resolve(firstWorkerRequest); if (firstWorkerRequest) return Promise.resolve(firstWorkerRequest);
@@ -686,8 +703,12 @@ async function installNetworkStubs(ctx, opts) {
module.exports = { module.exports = {
installNetworkStubs, installNetworkStubs,
DAPP_HTML,
DAPP_ORIGIN, DAPP_ORIGIN,
DAPP_URL, DAPP_URL,
PHISHING_DAPP_ORIGIN,
PHISHING_DAPP_URL,
WORKER_PROBE_URL,
FEE_ESTIMATE_WEI, FEE_ESTIMATE_WEI,
FEE_RESERVE_WEI, FEE_RESERVE_WEI,
STUB_COUNTERPARTY, STUB_COUNTERPARTY,

View File

@@ -33,6 +33,7 @@ const {
const { const {
DAPP_ORIGIN, DAPP_ORIGIN,
DAPP_URL, DAPP_URL,
PHISHING_DAPP_URL,
FEE_ESTIMATE_WEI, FEE_ESTIMATE_WEI,
FEE_RESERVE_WEI, FEE_RESERVE_WEI,
STUB_COUNTERPARTY, STUB_COUNTERPARTY,
@@ -42,6 +43,7 @@ const {
STUB_TX_HASH, STUB_TX_HASH,
} = require("./network"); } = require("./network");
const { DUST_THRESHOLD_MESSAGE } = require("../../src/popup/dustThreshold"); const { DUST_THRESHOLD_MESSAGE } = require("../../src/popup/dustThreshold");
const { NETWORKS } = require("../../src/shared/networks");
const TEST_TIMEOUT_MS = 120000; const TEST_TIMEOUT_MS = 120000;
@@ -68,7 +70,11 @@ function withTimeout(promise, name) {
const timeout = new Promise((_, reject) => { const timeout = new Promise((_, reject) => {
timer = setTimeout( timer = setTimeout(
() => () =>
reject(new Error("timed out after " + TEST_TIMEOUT_MS + "ms")), reject(
new Error(
name + ": timed out after " + TEST_TIMEOUT_MS + "ms",
),
),
TEST_TIMEOUT_MS, TEST_TIMEOUT_MS,
); );
}); });
@@ -940,6 +946,328 @@ test("confirming removes the address and returns Home (#162)", async (env) => {
); );
}); });
// ------------------------------------------------- Settings screen (#229)
// Settings holds the densest run of $("...") lookups in the codebase, and
// until this section nothing drove it in a browser. One wrong id makes
// settings.init() throw, which aborts the rest of index.js init() before it
// renders anything at all — so a broken id does not degrade Settings, it
// leaves the whole popup blank. These tests assert the controls are there
// AND that they work, because "the view is visible" would still pass
// against a screen whose handlers were never wired.
// The four Token Spam Protection checkboxes, in markup order, with the
// src/shared/state.js key each one is bound to. All four default true.
const SPAM_FILTER_CHECKBOXES = [
{ id: "settings-hide-spoofed-symbols", key: "hideSpoofedSymbols" },
{ id: "settings-hide-low-holders", key: "hideLowHolderTokens" },
{ id: "settings-hide-fraud-contracts", key: "hideFraudContracts" },
{ id: "settings-hide-dust", key: "hideDustTransactions" },
];
// The one toggled through a reopen. Chosen because nothing later in this
// suite depends on it: the other three filter token and transaction lists
// that the ConfirmTx and dApp sections go on to drive.
const TOGGLED_FILTER = "settings-hide-dust";
// Everything the Settings assertions below must observe, recorded as each
// group of them completes. The final test demands the exact set.
//
// The point is that a green run cannot mean the assertions were skipped.
// Navigation that silently fails already fails a test — visible() throws
// on a timeout — but an early return, a deleted test, or a body that
// stopped being reached would otherwise shrink this section quietly
// instead of reddening the run.
const SETTINGS_COVERAGE = [
"about-well",
"spam-checkbox-defaults",
"theme-select",
"network-select",
"selector-round-trip",
"selector-restore",
"toggle-off-survives-reopen",
"toggle-on-survives-reopen",
"wallet-list",
];
// A control read as the DOM has it, not as a selector claims: tag name and
// type distinguish a real <input type="checkbox"> from a <div> that merely
// carries the id, and `checked` is the live property rather than the
// attribute, so it reflects what init() assigned.
function controlState(page, id) {
return page.evaluate((elementId) => {
const el = document.getElementById(elementId);
if (!el) return null;
return {
tag: el.tagName.toLowerCase(),
type: el.type || "",
checked: el.checked,
value: el.value,
options: Array.from(el.options || []).map((o) => o.value),
};
}, id);
}
async function checkboxStates(page) {
const out = {};
for (const { id } of SPAM_FILTER_CHECKBOXES) {
out[id] = await controlState(page, id);
}
return out;
}
function assertSpamCheckbox(st, id, expected, where) {
assert(st !== null, "no element with id " + id + " on Settings " + where);
assert(
st.tag === "input" && st.type === "checkbox",
id + " is a <" + st.tag + " type=" + st.type + ">, not a checkbox",
);
assert(
st.checked === expected,
id +
" reads " +
st.checked +
" " +
where +
", expected " +
expected +
" — the checkbox is on screen but not carrying the persisted value",
);
}
test("Settings renders with the whole screen populated (#229)", async (env) => {
await visible(env.page, "#view-main");
await openSettings(env.page);
// show() writes the About well near its end — only the debug well and
// the debug-mode checkbox follow it — and showView() is the last thing
// of all, so an id show() cannot find aborts before Settings is ever
// displayed. Reading these values back proves show() ran through to
// there, not just far enough to unhide the section. They are filled
// from build-time constants that always have a value, so empty means
// the write did not happen.
const about = await env.page.evaluate(() => {
const out = {};
for (const id of [
"about-license",
"about-author",
"about-version",
"about-release-date",
"about-commit-link",
]) {
const el = document.getElementById(id);
out[id] = el === null ? null : el.textContent.trim();
}
return out;
});
for (const [id, text] of Object.entries(about)) {
assert(
text !== null && text.length > 0,
"the About well left #" +
id +
" unwritten: " +
JSON.stringify(about),
);
}
env.settingsCoverage.add("about-well");
// The wallet list is rendered by settings.js rather than authored in
// index.html, so an empty container means renderWalletListSettings()
// did not run even though the screen came up.
const wallets = await env.page
.locator("#settings-wallet-list .settings-wallet-name")
.count();
assert(
wallets >= 2,
"Settings lists " +
wallets +
" wallets; the suite created two by this point",
);
env.settingsCoverage.add("wallet-list");
});
test("the four Token Spam Protection checkboxes render, defaulted on (#229)", async (env) => {
await openSettings(env.page);
const states = await checkboxStates(env.page);
for (const { id } of SPAM_FILTER_CHECKBOXES) {
assertSpamCheckbox(states[id], id, true, "on first render");
}
env.settingsCoverage.add("spam-checkbox-defaults");
});
test("the theme and network selectors render their real choices (#229)", async (env) => {
await openSettings(env.page);
const theme = await controlState(env.page, "settings-theme");
assert(theme !== null, "no #settings-theme element on Settings");
assert(
theme.tag === "select",
"#settings-theme is a <" + theme.tag + ">, not a <select>",
);
assert(
theme.options.join(",") === "system,light,dark",
"the theme selector offers " + JSON.stringify(theme.options),
);
env.settingsCoverage.add("theme-select");
const network = await controlState(env.page, "settings-network");
assert(network !== null, "no #settings-network element on Settings");
assert(
network.tag === "select",
"#settings-network is a <" + network.tag + ">, not a <select>",
);
const wantNetworks = Object.keys(NETWORKS).sort().join(",");
assert(
network.options.slice().sort().join(",") === wantNetworks,
"the network selector offers " +
JSON.stringify(network.options) +
", expected the networks in src/shared/networks.js: " +
wantNetworks,
);
env.settingsCoverage.add("network-select");
});
// Reads both selectors in one page task, so a round trip cannot observe
// them at two different moments.
async function selectorValues(page) {
const theme = await controlState(page, "settings-theme");
const network = await controlState(page, "settings-network");
assert(theme !== null, "no #settings-theme element on Settings");
assert(network !== null, "no #settings-network element on Settings");
return { theme: theme.value, network: network.value };
}
function assertSelectors(got, wantTheme, wantNetwork, where) {
assert(
got.theme === wantTheme,
"the theme selector shows " +
JSON.stringify(got.theme) +
" " +
where +
", expected " +
JSON.stringify(wantTheme),
);
assert(
got.network === wantNetwork,
"the network selector shows " +
JSON.stringify(got.network) +
" " +
where +
", expected " +
JSON.stringify(wantNetwork),
);
}
// The two values the selectors are driven to. NEITHER is the first
// <option> of its <select> (`system` and `mainnet` are), and that is the
// entire point: the first option is what the DOM reports with no
// JavaScript involved at all, so asserting it would pass just as happily
// against a Settings screen that never assigned anything. Only a value
// that went out through the change handler and saveState(), and came back
// through loadState() and the assignment show()/init() makes, can be read
// here.
const NONDEFAULT_THEME = "dark";
const NONDEFAULT_NETWORK = "sepolia";
test("the theme and network selectors carry a non-default persisted value (#229)", async (env) => {
await openSettings(env.page);
// selectOption() fires "change", which is what the handlers bind.
await env.page.selectOption("#settings-theme", NONDEFAULT_THEME);
await env.page.selectOption("#settings-network", NONDEFAULT_NETWORK);
await reopenPopup(env, "#view-settings");
assertSelectors(
await selectorValues(env.page),
NONDEFAULT_THEME,
NONDEFAULT_NETWORK,
"after reopening the popup",
);
env.settingsCoverage.add("selector-round-trip");
// Restore, the same way round, and assert the restore actually took
// rather than trusting it: the later sections inherit this fixture,
// and a selector stuck on `dark`/`sepolia` would otherwise be
// indistinguishable here from one that persists correctly. Switching
// the network back also returns state.rpcUrl and state.blockscoutUrl
// to the mainnet defaults that onChainSwitch() overwrote, which are
// the values src/shared/state.js starts with.
await env.page.selectOption("#settings-theme", "system");
await env.page.selectOption("#settings-network", "mainnet");
await reopenPopup(env, "#view-settings");
assertSelectors(
await selectorValues(env.page),
"system",
"mainnet",
"after restoring and reopening the popup",
);
env.settingsCoverage.add("selector-restore");
});
// The functional half. A checkbox that renders but is not wired looks
// identical on screen; only a value that survives being written to storage
// and read back by a fresh page load tells the two apart. That round trip
// runs through the change handler, saveState(), loadState() and the
// assignment init() makes — every part of the wiring at once.
test("a spam filter toggled in Settings survives a popup reopen (#229)", async (env) => {
await openSettings(env.page);
await env.page.click("#" + TOGGLED_FILTER);
const immediately = await controlState(env.page, TOGGLED_FILTER);
assert(
immediately.checked === false,
"clicking #" + TOGGLED_FILTER + " did not clear it",
);
await reopenPopup(env, "#view-settings");
const after = await checkboxStates(env.page);
for (const { id } of SPAM_FILTER_CHECKBOXES) {
assertSpamCheckbox(
after[id],
id,
id !== TOGGLED_FILTER,
"after reopening the popup",
);
}
env.settingsCoverage.add("toggle-off-survives-reopen");
});
test("turning the same filter back on survives a reopen too (#229)", async (env) => {
await openSettings(env.page);
await env.page.click("#" + TOGGLED_FILTER);
await reopenPopup(env, "#view-settings");
// Restores the fixture the later sections inherit, and rules out a
// checkbox that persists "off" only because it is stuck there.
const after = await checkboxStates(env.page);
for (const { id } of SPAM_FILTER_CHECKBOXES) {
assertSpamCheckbox(after[id], id, true, "after toggling back on");
}
env.settingsCoverage.add("toggle-on-survives-reopen");
await env.page.click("#btn-settings-back");
await visible(env.page, "#view-main");
});
test("the Settings assertions above all ran (#229)", async (env) => {
const seen = [...env.settingsCoverage].sort();
const want = SETTINGS_COVERAGE.slice().sort();
assert(
seen.join(",") === want.join(","),
"the Settings section covered " +
JSON.stringify(seen) +
" but must cover " +
JSON.stringify(want) +
" — a green run here would otherwise mean only that fewer " +
"assertions ran, not that they passed",
);
});
// ------------------------------------------------ dust threshold (#233) // ------------------------------------------------ dust threshold (#233)
// The popup size README documents the UI as designed for. Pages in this // The popup size README documents the UI as designed for. Pages in this
@@ -1742,9 +2070,9 @@ async function extensionActiveAddress(page) {
return getAddress(address); return getAddress(address);
} }
async function openDapp(ctx) { async function openDapp(ctx, url = DAPP_URL) {
const page = await ctx.newPage(); const page = await ctx.newPage();
await page.goto(DAPP_URL); await page.goto(url);
// window.ethereum is not the fixture's doing — it is the shipped // window.ethereum is not the fixture's doing — it is the shipped
// MAIN-world content script. Waiting for it is waiting for the real // MAIN-world content script. Waiting for it is waiting for the real
// provider to have injected itself into a real http(s) origin. // provider to have injected itself into a real http(s) origin.
@@ -1856,32 +2184,13 @@ async function reserveApprovalTab(env) {
// one down with it. // one down with it.
env.approvalTab = await env.ctx.newPage(); env.approvalTab = await env.ctx.newPage();
// The one accommodation this section makes to the shipped code, and the // This tab runs the shipped popup with nothing patched. The site
// reason for it. // approval buttons decide and then close on the next line, and the two
// // site-approval tests below are therefore the real-browser
// Both approval buttons call runtime.sendMessage() and then window.close() // approve-then-immediate-close and reject-then-immediate-close cases: the
// on the next line. Closing this page disconnects the approval port, and // decision rides the approval port, which also carries the disconnect the
// the disconnect handler in src/background/index.js settles a pending // close causes, so it is delivered ahead of it and the outcome does not
// site approval as a rejection. In a tab those two race and the teardown // depend on the teardown timing (#275).
// wins: the approve message is never acted on, and the page is told the
// user rejected. Measured — with the close left in place the approval
// resolves as a rejection every time; with it deferred it resolves as an
// approval every time.
//
// It is deferred, not removed: the harness closes the page itself once
// the outcome has been observed, which is what window.close() would have
// done, only after the message it was racing has been processed.
//
// This affects the site-connection prompt only. The sign and transaction
// prompts run in windows the extension opens itself, with window.close()
// untouched, and their disconnect handler deliberately keeps a tx or sign
// approval pending rather than rejecting it — so there is no race there
// to accommodate. Whether the same ordering holds in a real toolbar popup
// is not observable from a headless harness and is reported rather than
// assumed either way.
await env.approvalTab.addInitScript(() => {
window.close = function () {};
});
await env.approvalTab.goto("about:blank"); await env.approvalTab.goto("about:blank");
await sleep(APPROVAL_TAB_SETTLE_MS); await sleep(APPROVAL_TAB_SETTLE_MS);
return env.approvalTab; return env.approvalTab;
@@ -1930,6 +2239,95 @@ async function closeApprovalPages(ctx) {
} }
} }
// Click a button whose own handler closes the window it lives in — every
// Reject, and Allow on the site prompt.
//
// page.click() dispatches the click and then waits for the renderer to
// acknowledge it, and a page torn down by the handler never gets to. The
// dispatch is what the test needs and the log shows it happening ("performing
// click action") immediately before the failure; the page going away is the
// button working, not the click failing. Observed on #btn-reject-sign and
// #btn-reject-tx, whose windows have always closed themselves.
//
// What the swallow costs is not the same for every button, so neither is what
// proves the click landed:
//
// #btn-reject-sign, #btn-reject-tx — their disconnect leaves the approval
// pending, so a click that never landed leaves the dApp promise unsettled
// and the assertion after the call fails on its own.
// #btn-approve — only a decision resolves the promise, and a swallowed click
// cannot produce settled === "resolved".
// #btn-reject on the site prompt — NOT self-proving. A page that went away
// without the click landing disconnects the approval port, the background
// settles that as 4001, and 4001 is exactly what assertUserRejection
// accepts. That call site arms the click trace below and asserts it.
//
// A button that is missing or unclickable raises a different error, which is
// rethrown.
async function clickAndClose(page, selector) {
try {
await page.click(selector);
} catch (e) {
if (!String((e && e.message) || e).includes("has been closed")) throw e;
}
}
// Evidence that a click reached the button, for the button whose outcome
// cannot tell.
//
// A capture-phase listener on the document runs ahead of the button's own
// handler and writes one key with localStorage.setItem(), which is synchronous
// and therefore already in the browser process when the handler tears the page
// down a line later. Any other page of the extension origin can read it back,
// and env.page is one. The listener only observes: nothing about the shipped
// decide-then-close is deferred, patched or reordered.
const CLICK_TRACE_KEY = "autistmask-e2e-click-landed";
async function armClickTrace(env, page, selector) {
await env.page.evaluate(
(key) => localStorage.removeItem(key),
CLICK_TRACE_KEY,
);
await page.evaluate(
({ key, sel }) => {
document.addEventListener(
"click",
(e) => {
const target = e.target;
if (target && target.closest && target.closest(sel)) {
localStorage.setItem(key, sel);
}
},
true,
);
},
{ key: CLICK_TRACE_KEY, sel: selector },
);
}
// The write crosses processes to reach env.page's renderer, so it is waited
// for rather than read once. Nothing else in the test is timed on this.
async function assertClickLanded(env, selector, timeout = 5000) {
const deadline = Date.now() + timeout;
let seen;
for (;;) {
seen = await env.page.evaluate(
(key) => localStorage.getItem(key),
CLICK_TRACE_KEY,
);
if (seen === selector || Date.now() > deadline) break;
await sleep(25);
}
assert(
seen === selector,
"the click on " +
selector +
" never reached the button, so the outcome below proves nothing " +
"about it: trace was " +
JSON.stringify(seen),
);
}
// Record every message the approval window sends to the background worker. // Record every message the approval window sends to the background worker.
// //
// This is the direct observation the password check needs. It is installed // This is the direct observation the password check needs. It is installed
@@ -2146,11 +2544,24 @@ test("eth_requestAccounts rejected at the prompt returns a rejection (#183)", as
JSON.stringify(hostname), JSON.stringify(hostname),
); );
// The control for the phishing test below: this origin is not on the
// blocklist, so the banner must be absent here. Without it a banner
// that was simply always visible would satisfy that test.
assert(
await popup.locator("#approve-site-phishing-warning").isHidden(),
"the phishing warning is showing for an origin that is not on " +
"the blocklist, so its appearance proves nothing",
);
// Deliberately not remembered: a remembered rejection lands the // Deliberately not remembered: a remembered rejection lands the
// origin in deniedSites and every later test in this section is // origin in deniedSites and every later test in this section is
// auto-rejected with no prompt at all, which would look like a pass. // auto-rejected with no prompt at all, which would look like a pass.
await popup.uncheck("#approve-remember"); await popup.uncheck("#approve-remember");
await popup.click("#btn-reject"); // The rejection this asserts is also what an unclicked prompt that
// simply went away produces, so the click itself is witnessed.
await armClickTrace(env, popup, "#btn-reject");
await clickAndClose(popup, "#btn-reject");
await assertClickLanded(env, "#btn-reject");
await assertUserRejection( await assertUserRejection(
env.dapp, env.dapp,
@@ -2181,7 +2592,7 @@ test("eth_requestAccounts approved returns the selected address (#183)", async (
// does not, and the sign and transaction tests below all require the // does not, and the sign and transaction tests below all require the
// origin to still be authorized. // origin to still be authorized.
await popup.check("#approve-remember"); await popup.check("#approve-remember");
await popup.click("#btn-approve"); await clickAndClose(popup, "#btn-approve");
outcome = await settleRequest(env.dapp, "accounts"); outcome = await settleRequest(env.dapp, "accounts");
} finally { } finally {
@@ -2205,6 +2616,53 @@ test("eth_requestAccounts approved returns the selected address (#183)", async (
); );
}); });
test("a connect request from a blocklisted site is flagged (#219)", async (env) => {
// The vendored blocklist, end to end: a real entry from the shipped
// artifact, served as a real http(s) origin, reaching the real background
// check and the real approval screen. Nothing about the list is stubbed —
// there is nothing left to stub, since the extension no longer fetches it.
const phishingDapp = await openDapp(env.ctx, PHISHING_DAPP_URL);
const hostname = new URL(PHISHING_DAPP_URL).hostname;
try {
await reserveApprovalTab(env);
await startRequest(
phishingDapp,
"phishing-accounts",
"eth_requestAccounts",
[],
);
const popup = await openSiteApprovalPopup(env);
try {
await visible(popup, "#view-approve-site");
const shown = await popup.locator("#approve-hostname").innerText();
assert(
shown === hostname,
"the site prompt names the wrong origin: " +
JSON.stringify(shown),
);
await visible(popup, "#approve-site-phishing-warning");
console.log("# phishing warning shown for " + hostname);
// Not remembered: a remembered decision for this origin would
// outlive the test.
await popup.uncheck("#approve-remember");
await popup.click("#btn-reject");
await assertUserRejection(
phishingDapp,
"phishing-accounts",
"the blocklisted site's eth_requestAccounts",
);
} finally {
await closeApprovalPages(env.ctx);
}
} finally {
await phishingDapp.close();
}
});
test("personal_sign signs, and the signature recovers to the address (#183)", async (env) => { test("personal_sign signs, and the signature recovers to the address (#183)", async (env) => {
await startRequest(env.dapp, "sign", "personal_sign", [ await startRequest(env.dapp, "sign", "personal_sign", [
SIGN_HEX, SIGN_HEX,
@@ -2289,7 +2747,7 @@ test("personal_sign rejected returns a rejection to the page (#183)", async (env
]); ]);
const popup = await waitForApprovalWindow(env.ctx); const popup = await waitForApprovalWindow(env.ctx);
await visible(popup, "#view-approve-sign"); await visible(popup, "#view-approve-sign");
await popup.click("#btn-reject-sign"); await clickAndClose(popup, "#btn-reject-sign");
await assertUserRejection( await assertUserRejection(
env.dapp, env.dapp,
@@ -2392,7 +2850,7 @@ test("eth_signTypedData_v4 rejected returns a rejection to the page (#183)", asy
]); ]);
const popup = await waitForApprovalWindow(env.ctx); const popup = await waitForApprovalWindow(env.ctx);
await visible(popup, "#view-approve-sign"); await visible(popup, "#view-approve-sign");
await popup.click("#btn-reject-sign"); await clickAndClose(popup, "#btn-reject-sign");
await assertUserRejection( await assertUserRejection(
env.dapp, env.dapp,
@@ -2550,7 +3008,7 @@ test("eth_sendTransaction rejected broadcasts nothing (#183)", async (env) => {
]); ]);
const popup = await waitForApprovalWindow(env.ctx); const popup = await waitForApprovalWindow(env.ctx);
await visible(popup, "#view-approve-tx"); await visible(popup, "#view-approve-tx");
await popup.click("#btn-reject-tx"); await clickAndClose(popup, "#btn-reject-tx");
await assertUserRejection( await assertUserRejection(
env.dapp, env.dapp,
@@ -2668,6 +3126,11 @@ async function main() {
// The recovery phrase of the wallet created in test 2, so later // The recovery phrase of the wallet created in test 2, so later
// tests can assert on the real secret rather than its shape. // tests can assert on the real secret rather than its shape.
phrase: null, phrase: null,
// What the Settings section (#229) actually observed. A guard test
// at the end of that section demands the full set, so a skipped or
// silently shortened assertion reddens the run instead of shrinking
// it.
settingsCoverage: new Set(),
// Confirmation-screen heights, measured in the pending state and // Confirmation-screen heights, measured in the pending state and
// compared against every later state of the same screen. // compared against every later state of the same screen.
ethPendingHeight: null, ethPendingHeight: null,

View File

@@ -1,93 +1,128 @@
// Extension storage stub for the Node test environment. The module resolves // The phishing blocklist is vendored at build time and shipped as digests:
// the storage API on use, so this only has to exist before the first call. // script/vendor-blocklist writes src/shared/phishingBlocklist.json, and nothing
// Values round-trip through JSON the way structured cloning would, so a test // fetches anything at runtime. Two things therefore have to be proven here, and
// cannot pass by holding a live reference to the module's own array. // the second is the one that would otherwise fail silently:
const storageStore = {}; //
global.chrome = { // - real domains from the vendored list are detected, and clean ones are not.
storage: { // - a malformed artifact fails loudly. Every way of getting the artifact
local: { // wrong produces a blocklist that matches nothing while looking healthy,
get: async (key) => // which is a phishing check that answers "no" to everything.
Object.prototype.hasOwnProperty.call(storageStore, key)
? { [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];
},
},
},
};
const { const {
isPhishingDomain, isPhishingDomain,
loadConfig,
getBlocklistSize, getBlocklistSize,
getDeltaSize,
hostnameVariants, hostnameVariants,
DELTA_STORAGE_KEY,
_reset,
_getVendoredBlacklistSize,
_getDeltaBlacklist,
} = require("../src/shared/phishingDomains"); } = require("../src/shared/phishingDomains");
const { HASH_HEX_CHARS, hashDomain } = require("../src/shared/domainHash");
const vendored = require("../src/shared/phishingBlocklist.json");
function clearStorage() { // Domains present in the vendored list at the pinned upstream commit. Upstream
for (const key of Object.keys(storageStore)) { // prunes as well as adds, so re-vendoring can retire one of these and turn this
delete storageStore[key]; // red; that is the intended prompt to pick a current entry, not a licence to
// weaken the assertion into "some domain somewhere matches".
const LISTED = [
"0-google.ph",
"myetheywallet.com",
// An underscore is not legal in a hostname, but DNS carries one and
// browsers resolve it, and upstream lists well over a hundred phishing
// sites that use one. The vendoring transform keeps them.
"phntum-wallett.godaddysites.com",
"coinbase_prologin1.godaddysites.com",
];
// Not on the list, and the kind of host a user actually visits.
const CLEAN = ["etherscan.io", "example.com", "opensea.io", "sneak.berlin"];
describe("vendored blocklist", () => {
test("the artifact holds the whole list", () => {
expect(getBlocklistSize()).toBeGreaterThan(100000);
expect(vendored.hashes).toHaveLength(vendored.count * HASH_HEX_CHARS);
});
test("the digests are sorted and unique", () => {
// The lookup is a binary search over the concatenated digests. An
// unsorted or duplicated artifact would fail lookups quietly rather
// than loudly, so the ordering the search depends on is asserted here
// against the committed file rather than assumed of the generator.
// One assertion at the end rather than one per entry: 100k+ expect()
// calls cost seconds, and make test is capped at 30 for the whole
// suite. The index of the first offender is reported, so a failure
// still says where.
let previous = "";
let outOfOrderAt = -1;
for (let i = 0; i < vendored.count; i++) {
const at = vendored.hashes.slice(
i * HASH_HEX_CHARS,
(i + 1) * HASH_HEX_CHARS,
);
if (at <= previous) {
outOfOrderAt = i;
break;
} }
} previous = at;
}
expect(outOfOrderAt).toBe(-1);
});
// The MV3 service worker is torn down when idle and re-evaluated on the next test("every digest is lowercase hex of the declared width", () => {
// event, which wipes every module-level variable. Re-requiring the module with expect(vendored.hashes).toMatch(/^[0-9a-f]*$/);
// 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. test("detects domains from the vendored list", () => {
// Note: vendored sets are immutable and always present. for (const domain of LISTED) {
beforeEach(() => { expect(isPhishingDomain(domain)).toBe(true);
_reset(); }
clearStorage(); });
test("does not flag legitimate domains", () => {
for (const domain of CLEAN) {
expect(isPhishingDomain(domain)).toBe(false);
}
});
test("detects a subdomain of a listed domain", () => {
expect(isPhishingDomain("wallet." + LISTED[0])).toBe(true);
expect(isPhishingDomain("a.b.c." + LISTED[0])).toBe(true);
});
test("matching is case-insensitive", () => {
expect(isPhishingDomain(LISTED[0].toUpperCase())).toBe(true);
});
test("returns false for an empty or missing hostname", () => {
expect(isPhishingDomain("")).toBe(false);
expect(isPhishingDomain(null)).toBe(false);
expect(isPhishingDomain(undefined)).toBe(false);
});
test("the first and last entries are both reachable", () => {
// The ends are where an off-by-one in a binary search hides: a search
// that never examines index 0 or index count-1 still finds everything
// in between, and the real list is not searched exhaustively here.
const first = vendored.hashes.slice(0, HASH_HEX_CHARS);
const last = vendored.hashes.slice(-HASH_HEX_CHARS);
const { _hashListed } = require("../src/shared/phishingDomains");
expect(_hashListed(first)).toBe(true);
expect(_hashListed(last)).toBe(true);
expect(_hashListed("0".repeat(HASH_HEX_CHARS))).toBe(false);
expect(_hashListed("f".repeat(HASH_HEX_CHARS))).toBe(false);
});
}); });
describe("phishingDomains", () => { describe("hostnameVariants", () => {
describe("vendored blocklist", () => {
test("vendored blacklist is loaded from bundled JSON", () => {
// The vendored blocklist should have a large number of entries
expect(_getVendoredBlacklistSize()).toBeGreaterThan(100000);
});
test("detects domains from vendored blacklist", () => {
// These are well-known phishing domains in the vendored list
expect(isPhishingDomain("hopprotocol.pro")).toBe(true);
expect(isPhishingDomain("blast-pools.pages.dev")).toBe(true);
});
test("getBlocklistSize includes vendored entries", () => {
expect(getBlocklistSize()).toBeGreaterThan(100000);
});
});
describe("hostnameVariants", () => {
test("returns exact hostname plus parent domains", () => { test("returns exact hostname plus parent domains", () => {
const variants = hostnameVariants("sub.evil.com"); expect(hostnameVariants("sub.evil.com")).toEqual([
expect(variants).toEqual(["sub.evil.com", "evil.com"]); "sub.evil.com",
"evil.com",
]);
}); });
test("returns just the hostname for a bare domain", () => { test("returns just the hostname for a bare domain", () => {
const variants = hostnameVariants("example.com"); expect(hostnameVariants("example.com")).toEqual(["example.com"]);
expect(variants).toEqual(["example.com"]);
}); });
test("handles deep subdomain chains", () => { test("handles deep subdomain chains", () => {
const variants = hostnameVariants("a.b.c.d.com"); expect(hostnameVariants("a.b.c.d.com")).toEqual([
expect(variants).toEqual([
"a.b.c.d.com", "a.b.c.d.com",
"b.c.d.com", "b.c.d.com",
"c.d.com", "c.d.com",
@@ -96,478 +131,89 @@ describe("phishingDomains", () => {
}); });
test("lowercases hostnames", () => { test("lowercases hostnames", () => {
const variants = hostnameVariants("Evil.COM"); expect(hostnameVariants("Evil.COM")).toEqual(["evil.com"]);
expect(variants).toEqual(["evil.com"]);
});
});
describe("delta computation via loadConfig", () => {
test("loadConfig computes delta of new entries not in vendored list", () => {
loadConfig({
blacklist: [
"brand-new-scam-site-xyz123.com",
"hopprotocol.pro", // already in vendored
],
});
// Only the new domain should be in the delta
expect(
_getDeltaBlacklist().has("brand-new-scam-site-xyz123.com"),
).toBe(true);
expect(_getDeltaBlacklist().has("hopprotocol.pro")).toBe(false);
expect(getDeltaSize()).toBe(1);
});
test("re-loading config replaces previous delta", () => {
loadConfig({
blacklist: ["first-scam-xyz.com"],
});
expect(isPhishingDomain("first-scam-xyz.com")).toBe(true);
loadConfig({
blacklist: ["second-scam-xyz.com"],
});
expect(isPhishingDomain("first-scam-xyz.com")).toBe(false);
expect(isPhishingDomain("second-scam-xyz.com")).toBe(true);
});
test("getBlocklistSize includes both vendored and delta", () => {
const baseSize = getBlocklistSize();
loadConfig({
blacklist: ["delta-only-scam-xyz.com"],
});
expect(getBlocklistSize()).toBe(baseSize + 1);
});
});
describe("isPhishingDomain with delta + vendored", () => {
test("detects domain from delta blacklist", () => {
loadConfig({
blacklist: ["fresh-scam-xyz.com"],
});
expect(isPhishingDomain("fresh-scam-xyz.com")).toBe(true);
});
test("detects domain from vendored blacklist", () => {
// No delta loaded — vendored still works
expect(isPhishingDomain("hopprotocol.pro")).toBe(true);
});
test("returns false for clean domains", () => {
expect(isPhishingDomain("etherscan.io")).toBe(false);
expect(isPhishingDomain("example.com")).toBe(false);
});
test("detects subdomain of blacklisted domain (vendored)", () => {
expect(isPhishingDomain("app.hopprotocol.pro")).toBe(true);
});
test("detects subdomain of blacklisted domain (delta)", () => {
loadConfig({
blacklist: ["delta-phish-xyz.com"],
});
expect(isPhishingDomain("sub.delta-phish-xyz.com")).toBe(true);
});
test("case-insensitive matching", () => {
loadConfig({
blacklist: ["Delta-Scam-XYZ.COM"],
});
expect(isPhishingDomain("delta-scam-xyz.com")).toBe(true);
expect(isPhishingDomain("DELTA-SCAM-XYZ.COM")).toBe(true);
});
test("returns false for empty/null hostname", () => {
expect(isPhishingDomain("")).toBe(false);
expect(isPhishingDomain(null)).toBe(false);
});
test("handles config with no blacklist key", () => {
loadConfig({});
expect(getDeltaSize()).toBe(0);
// Vendored list still works
expect(isPhishingDomain("hopprotocol.pro")).toBe(true);
});
});
describe("extension storage persistence", () => {
test("delta is persisted to extension storage, not localStorage", async () => {
await loadConfig({
blacklist: ["persisted-scam-xyz.com"],
});
const stored = storageStore[DELTA_STORAGE_KEY];
expect(stored).toBeDefined();
expect(stored.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", () => {
loadConfig({
blacklist: ["temp-scam-xyz.com"],
});
expect(getDeltaSize()).toBe(1);
_reset();
expect(getDeltaSize()).toBe(0);
});
});
describe("real-world blocklist patterns", () => {
test("detects known phishing domains from vendored list", () => {
expect(isPhishingDomain("uniswap-trade.web.app")).toBe(true);
expect(isPhishingDomain("hopprotocol.pro")).toBe(true);
expect(isPhishingDomain("blast-pools.pages.dev")).toBe(true);
});
test("does not flag legitimate domains", () => {
expect(isPhishingDomain("opensea.io")).toBe(false);
expect(isPhishingDomain("etherscan.io")).toBe(false);
});
}); });
}); });
describe("phishing list across a service worker restart", () => { describe("domain hashing", () => {
beforeEach(() => { test("a digest is the declared width of lowercase hex", () => {
clearStorage(); const hash = hashDomain("example.com");
jest.resetModules(); expect(hash).toHaveLength(HASH_HEX_CHARS);
expect(hash).toMatch(/^[0-9a-f]+$/);
}); });
afterEach(() => { test("hashing is case-insensitive, so lookups are too", () => {
delete global.fetch; expect(hashDomain("Evil.COM")).toBe(hashDomain("evil.com"));
}); });
test("a revived worker restores the persisted delta without re-fetching", async () => { test("different domains get different digests", () => {
const first = require("../src/shared/phishingDomains"); expect(hashDomain("evil.com")).not.toBe(hashDomain("evil.org"));
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 // A blocklist that silently matches nothing is the failure this module must not
// the fetch completes, so it lands one fetch latency after the alarm that // have, so each way of breaking the artifact is required to throw at load. The
// caused it; a freshness guard timed to the alarm period therefore vetoes // generator is the only thing that writes this file, but "the generator is
// every scheduled tick and halves the real refresh rate. These tests measure // correct" is not something the shipped extension can check at runtime — this
// the interval between fetches that actually happened. // is what makes a format drift a build failure rather than a silent one.
describe("phishing refresh steady-state cadence", () => { describe("a malformed artifact fails loudly", () => {
const { PHISHING_REFRESH_PERIOD_MINUTES } = require("../src/shared/alarms"); const GOOD = {
const PERIOD_MS = PHISHING_REFRESH_PERIOD_MINUTES * 60 * 1000; algorithm: "sha256",
hashHexChars: HASH_HEX_CHARS,
count: 2,
hashes: "0".repeat(HASH_HEX_CHARS) + "1".repeat(HASH_HEX_CHARS),
};
let clockSpy; function loadWith(artifact) {
let now; let mod;
jest.isolateModules(() => {
beforeEach(() => { jest.doMock(
clearStorage(); "../src/shared/phishingBlocklist.json",
jest.resetModules(); () => artifact,
now = Date.UTC(2026, 0, 1, 0, 0, 0); {
clockSpy = jest.spyOn(Date, "now").mockImplementation(() => now); virtual: false,
},
);
mod = require("../src/shared/phishingDomains");
}); });
return mod;
}
afterEach(() => { afterEach(() => {
clockSpy.mockRestore(); jest.dontMock("../src/shared/phishingBlocklist.json");
delete global.fetch;
}); });
function fetchStub(latencyMs, seen) { test("the control artifact loads", () => {
return jest.fn(async () => { expect(loadWith(GOOD).getBlocklistSize()).toBe(2);
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 () => { test("a different digest algorithm throws", () => {
// The alarm fires one period after the previous alarm, which is expect(() => loadWith({ ...GOOD, algorithm: "md5" })).toThrow(
// `latency` short of one period since the fetch it caused completed. /algorithm/,
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 () => { test("a different digest width throws", () => {
const counter = { calls: 0 }; expect(() => loadWith({ ...GOOD, hashHexChars: 8 })).toThrow(
global.fetch = countingFetch(counter, () => ({ /hex characters per entry/,
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 () => { test("a count that does not match the string length throws", () => {
const { expect(() => loadWith({ ...GOOD, count: 3 })).toThrow(
MIN_FETCH_ATTEMPT_INTERVAL_MS, /which is not the/,
} = 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 () => { test("a missing hashes string throws", () => {
// The alarm period is far above the floor, but the floor exists to expect(() => loadWith({ ...GOOD, hashes: undefined })).toThrow(
// throttle wakes, not the schedule. /no hashes string/,
storageStore[DELTA_STORAGE_KEY] = { lastAttemptTime: now - 1000 }; );
const mod = require("../src/shared/phishingDomains"); });
global.fetch = okFetch();
await mod.refreshPhishingListOnSchedule(); test("an empty artifact throws rather than matching nothing", () => {
expect(global.fetch).toHaveBeenCalledTimes(1); expect(() => loadWith({ ...GOOD, count: 0, hashes: "" })).toThrow(
/entry count/,
);
}); });
}); });

View File

@@ -0,0 +1,182 @@
// Every element id the popup views look up must exist in the markup they
// look it up in.
//
// The failure this catches: `$("settings-hide-dsut")` is valid JavaScript
// referring to a defined function, so neither jest (node environment, no
// DOM) nor a linter has anything to object to. At runtime `$()` returns
// null and the next property access throws, which in `init()` aborts the
// rest of that view's wiring and takes the whole screen down. Settings is
// the densest concentration of these lookups in the codebase.
//
// This is the cheap general half of the guard: it runs in `make check`
// with no browser and covers every id in every view, not the ones some
// test happens to click. The expensive specific half is the Settings
// section of the end-to-end suite (tests/e2e/run.js), which proves the
// screen actually comes up and its controls work.
//
// Scope and limits, stated rather than implied:
// - Only literal string arguments are resolvable statically. A call
// like `$(containerId)` is invisible here; those are covered by the
// e2e run instead.
// - `document.getElementById()` is checked too, minus the ids listed in
// RUNTIME_CREATED_IDS, which name nodes the code creates itself and
// which are legitimately absent from the static markup.
"use strict";
const fs = require("fs");
const path = require("path");
const POPUP_DIR = path.join(__dirname, "..", "src", "popup");
const POPUP_HTML_PATH = path.join(POPUP_DIR, "index.html");
// Nodes built at runtime rather than authored in index.html. Each one must
// be created unconditionally by the code before it is ever looked up.
const RUNTIME_CREATED_IDS = new Set([
// Created by updateDebugBanner() in src/popup/views/helpers.js.
"debug-banner",
]);
// Every id lookup the popup performs with a literal argument, as
// {id, file, line, source} records.
//
// showView("x") is included because it resolves to the element id
// "view-x": a view name with no matching section is the same defect one
// indirection further out.
const PATTERNS = [
{ re: /\$\(\s*"([^"\n]+)"\s*\)/g, id: (m) => m[1], source: "$()" },
{
re: /document\.getElementById\(\s*"([^"\n]+)"\s*\)/g,
id: (m) => m[1],
source: "getElementById()",
},
{
re: /\b(?:showError|hideError)\(\s*"([^"\n]+)"/g,
id: (m) => m[1],
source: "showError()/hideError()",
},
{
re: /\bshowView\(\s*"([^"\n]+)"\s*\)/g,
id: (m) => "view-" + m[1],
source: "showView()",
},
];
function jsFilesUnder(dir) {
const out = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
out.push(...jsFilesUnder(full));
} else if (entry.name.endsWith(".js")) {
out.push(full);
}
}
return out.sort();
}
function lineOf(text, index) {
return text.slice(0, index).split("\n").length;
}
function collectReferences() {
const refs = [];
for (const file of jsFilesUnder(POPUP_DIR)) {
const text = fs.readFileSync(file, "utf8");
const rel = path.relative(path.join(__dirname, ".."), file);
for (const { re, id, source } of PATTERNS) {
re.lastIndex = 0;
let m;
while ((m = re.exec(text)) !== null) {
refs.push({
id: id(m),
file: rel,
line: lineOf(text, m.index),
source,
});
}
}
}
return refs;
}
function collectHtmlIds(html) {
const ids = [];
const re = /\bid="([^"]+)"/g;
let m;
while ((m = re.exec(html)) !== null) ids.push(m[1]);
return ids;
}
const HTML = fs.readFileSync(POPUP_HTML_PATH, "utf8");
const HTML_IDS = collectHtmlIds(HTML);
const HTML_ID_SET = new Set(HTML_IDS);
const REFERENCES = collectReferences();
describe("every element id the popup looks up exists in its markup", () => {
// A guard that found nothing to check would pass forever. If a
// refactor renames the directory, changes the helper, or moves the
// markup, this fails instead of quietly covering zero call sites.
// The floors are far below the counts measured when this was written
// (434 lookups across 20 of the 24 files under src/popup/, against 274
// ids in the markup), so ordinary churn does not trip them.
test("the scan actually found the code and the markup", () => {
const files = new Set(REFERENCES.map((r) => r.file));
expect(files.size).toBeGreaterThanOrEqual(15);
expect(REFERENCES.length).toBeGreaterThanOrEqual(300);
expect(HTML_IDS.length).toBeGreaterThanOrEqual(200);
// The densest screen, named explicitly: a scan that stopped
// covering src/popup/views/settings.js is the exact regression
// this file was written for.
expect(
files.has(path.join("src", "popup", "views", "settings.js")),
).toBe(true);
expect(
REFERENCES.some((r) => r.id === "settings-hide-spoofed-symbols"),
).toBe(true);
expect(REFERENCES.some((r) => r.id === "view-settings")).toBe(true);
});
test("no lookup names an id that src/popup/index.html does not define", () => {
const missing = REFERENCES.filter(
(r) => !HTML_ID_SET.has(r.id) && !RUNTIME_CREATED_IDS.has(r.id),
).map(
(r) =>
r.file +
":" +
r.line +
" " +
r.source +
' looks up id "' +
r.id +
'", which is not in src/popup/index.html',
);
expect(missing).toEqual([]);
});
test("every id excused as runtime-created is still looked up somewhere", () => {
// Otherwise the exception list becomes a place stale names
// accumulate, and the next real miss can be waved through by
// adding one more.
for (const id of RUNTIME_CREATED_IDS) {
expect(REFERENCES.some((r) => r.id === id)).toBe(true);
expect(HTML_ID_SET.has(id)).toBe(false);
}
});
test("index.html defines no id twice", () => {
// getElementById returns the first match, so a duplicate id means
// one of the two elements can never be reached by the code that
// thinks it owns it.
const seen = new Set();
const duplicated = [];
for (const id of HTML_IDS) {
if (seen.has(id)) duplicated.push(id);
seen.add(id);
}
expect(duplicated).toEqual([]);
});
});

View File

@@ -384,7 +384,7 @@ describe("the shipped token list", () => {
"0xab5eb14c09d416f0ac63661e57edb7aecdb9befa", // Metronome Synth USD "0xab5eb14c09d416f0ac63661e57edb7aecdb9befa", // Metronome Synth USD
], ],
MUSD: [ MUSD: [
"0xaca92e438df0b2401ff60da7e4337b687a2435da", // MetaMask USD "0xaca92e438df0b2401ff60da7e4337b687a2435da",
"0xdd468a1ddc392dcdbef6db6e34e89aa338f9f186", // Mezo USD "0xdd468a1ddc392dcdbef6db6e34e89aa338f9f186", // Mezo USD
], ],
JPYC: [ JPYC: [

View File

@@ -1,4 +1,4 @@
const { AbiCoder, Interface, solidityPacked, getBytes } = require("ethers"); const { AbiCoder, Interface, solidityPacked } = require("ethers");
const uniswap = require("../src/shared/uniswap"); const uniswap = require("../src/shared/uniswap");
const ROUTER_ADDR = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af"; const ROUTER_ADDR = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";

380
yarn.lock
View File

@@ -427,6 +427,90 @@
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz#0eaf705c941a218a43dba8e09f1df1d6cd2f1f17" resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz#0eaf705c941a218a43dba8e09f1df1d6cd2f1f17"
integrity sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA== integrity sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==
"@eslint-community/eslint-utils@^4.8.0":
version "4.10.1"
resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz#8911bd72b2c3640a543609e0400b8c4d2e7e7cb6"
integrity sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==
dependencies:
eslint-visitor-keys "^3.4.3"
"@eslint-community/regexpp@^4.12.2":
version "4.12.2"
resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b"
integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==
"@eslint/config-array@^0.23.5":
version "0.23.5"
resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.23.5.tgz#56e86d243049195d8acc0c06a1b3dfdc3fa3de95"
integrity sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==
dependencies:
"@eslint/object-schema" "^3.0.5"
debug "^4.3.1"
minimatch "^10.2.4"
"@eslint/config-helpers@^0.7.0":
version "0.7.0"
resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.7.0.tgz#09ee4aa07b73f059ec2d4c74bf4b2ff02b322377"
integrity sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==
dependencies:
"@eslint/core" "^1.2.1"
"@eslint/core@^1.2.1":
version "1.2.1"
resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.2.1.tgz#c1da7cd1b82fa8787f98b5629fb811848a1b63ce"
integrity sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==
dependencies:
"@types/json-schema" "^7.0.15"
"@eslint/js@10.0.1":
version "10.0.1"
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-10.0.1.tgz#1e8a876f50117af8ab67e47d5ad94d38d6622583"
integrity sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==
"@eslint/object-schema@^3.0.5":
version "3.0.5"
resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-3.0.5.tgz#88e9bf4d11d2b19c082e78ebe7ce88724a5eb091"
integrity sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==
"@eslint/plugin-kit@^0.7.2":
version "0.7.2"
resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz#4b0962f3f2c7ce8bc98b3ecfe34525c09d2cb729"
integrity sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==
dependencies:
"@eslint/core" "^1.2.1"
levn "^0.4.1"
"@humanfs/core@^0.19.2":
version "0.19.2"
resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.2.tgz#a8272ca03b2acf492670222b2320b6c421bfde60"
integrity sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==
dependencies:
"@humanfs/types" "^0.15.0"
"@humanfs/node@^0.16.6":
version "0.16.8"
resolved "https://registry.yarnpkg.com/@humanfs/node/-/node-0.16.8.tgz#8f800cccc13f4f8cd3116e2d9c0a94939da3e3ed"
integrity sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==
dependencies:
"@humanfs/core" "^0.19.2"
"@humanfs/types" "^0.15.0"
"@humanwhocodes/retry" "^0.4.0"
"@humanfs/types@^0.15.0":
version "0.15.0"
resolved "https://registry.yarnpkg.com/@humanfs/types/-/types-0.15.0.tgz#f2a09f62012390b2bff3fc6fb248ddec8c09a090"
integrity sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==
"@humanwhocodes/module-importer@^1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c"
integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==
"@humanwhocodes/retry@^0.4.0", "@humanwhocodes/retry@^0.4.2":
version "0.4.3"
resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba"
integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==
"@isaacs/cliui@^8.0.2": "@isaacs/cliui@^8.0.2":
version "8.0.2" version "8.0.2"
resolved "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz" resolved "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz"
@@ -1008,6 +1092,16 @@
dependencies: dependencies:
"@babel/types" "^7.28.2" "@babel/types" "^7.28.2"
"@types/esrecurse@^4.3.1":
version "4.3.1"
resolved "https://registry.yarnpkg.com/@types/esrecurse/-/esrecurse-4.3.1.tgz#6f636af962fbe6191b830bd676ba5986926bccec"
integrity sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==
"@types/estree@^1.0.6", "@types/estree@^1.0.8":
version "1.0.9"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.9.tgz#cf3f0e876d7bee15a93ab925b82bf570a3904a24"
integrity sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==
"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.1", "@types/istanbul-lib-coverage@^2.0.6": "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.1", "@types/istanbul-lib-coverage@^2.0.6":
version "2.0.6" version "2.0.6"
resolved "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz" resolved "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz"
@@ -1027,6 +1121,11 @@
dependencies: dependencies:
"@types/istanbul-lib-report" "*" "@types/istanbul-lib-report" "*"
"@types/json-schema@^7.0.15":
version "7.0.15"
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
"@types/node@*", "@types/node@22.7.5": "@types/node@*", "@types/node@22.7.5":
version "22.7.5" version "22.7.5"
resolved "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz" resolved "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz"
@@ -1153,11 +1252,31 @@
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz#538b1e103bf8d9864e7b85cc96fa8d6fb6c40777" resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz#538b1e103bf8d9864e7b85cc96fa8d6fb6c40777"
integrity sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g== integrity sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==
acorn-jsx@^5.3.2:
version "5.3.2"
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
acorn@^8.16.0:
version "8.18.0"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.18.0.tgz#4faf01b2d6d326bfeed97aea1f52220b5f4c1940"
integrity sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==
aes-js@4.0.0-beta.5: aes-js@4.0.0-beta.5:
version "4.0.0-beta.5" version "4.0.0-beta.5"
resolved "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz" resolved "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz"
integrity sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q== integrity sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==
ajv@^6.14.0:
version "6.15.0"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.15.0.tgz#07e982c74626167aa7a2495c53817892d7139492"
integrity sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==
dependencies:
fast-deep-equal "^3.1.1"
fast-json-stable-stringify "^2.0.0"
json-schema-traverse "^0.4.1"
uri-js "^4.2.2"
ansi-escapes@^4.3.2: ansi-escapes@^4.3.2:
version "4.3.2" version "4.3.2"
resolved "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz" resolved "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz"
@@ -1297,6 +1416,13 @@ brace-expansion@^5.0.2:
dependencies: dependencies:
balanced-match "^4.0.2" balanced-match "^4.0.2"
brace-expansion@^5.0.8:
version "5.0.9"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.9.tgz#7c72438809b5fa5babf54199a1f1c281a6984fcf"
integrity sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==
dependencies:
balanced-match "^4.0.2"
braces@^3.0.3: braces@^3.0.3:
version "3.0.3" version "3.0.3"
resolved "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz" resolved "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz"
@@ -1429,7 +1555,7 @@ cross-spawn@^7.0.3, cross-spawn@^7.0.6:
shebang-command "^2.0.0" shebang-command "^2.0.0"
which "^2.0.1" which "^2.0.1"
debug@^4.1.0, debug@^4.1.1, debug@^4.3.1: debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2:
version "4.4.3" version "4.4.3"
resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz" resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz"
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
@@ -1446,6 +1572,11 @@ dedent@^1.6.0:
resolved "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz" resolved "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz"
integrity sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg== integrity sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==
deep-is@^0.1.3:
version "0.1.4"
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
deepmerge@^4.3.1: deepmerge@^4.3.1:
version "4.3.1" version "4.3.1"
resolved "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz" resolved "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz"
@@ -1548,11 +1679,105 @@ escape-string-regexp@^2.0.0:
resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz" resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz"
integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==
escape-string-regexp@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
eslint-scope@^9.1.2:
version "9.1.2"
resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-9.1.2.tgz#b9de6ace2fab1cff24d2e58d85b74c8fcea39802"
integrity sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==
dependencies:
"@types/esrecurse" "^4.3.1"
"@types/estree" "^1.0.8"
esrecurse "^4.3.0"
estraverse "^5.2.0"
eslint-visitor-keys@^3.4.3:
version "3.4.3"
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800"
integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==
eslint-visitor-keys@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be"
integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==
eslint@10.8.1:
version "10.8.1"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.8.1.tgz#fb37d514c19b6dd5b2d6b70169fd26fddfa97967"
integrity sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==
dependencies:
"@eslint-community/eslint-utils" "^4.8.0"
"@eslint-community/regexpp" "^4.12.2"
"@eslint/config-array" "^0.23.5"
"@eslint/config-helpers" "^0.7.0"
"@eslint/core" "^1.2.1"
"@eslint/plugin-kit" "^0.7.2"
"@humanfs/node" "^0.16.6"
"@humanwhocodes/module-importer" "^1.0.1"
"@humanwhocodes/retry" "^0.4.2"
"@types/estree" "^1.0.6"
ajv "^6.14.0"
cross-spawn "^7.0.6"
debug "^4.3.2"
escape-string-regexp "^4.0.0"
eslint-scope "^9.1.2"
eslint-visitor-keys "^5.0.1"
espree "^11.2.0"
esquery "^1.7.0"
esutils "^2.0.2"
fast-deep-equal "^3.1.3"
file-entry-cache "^8.0.0"
find-up "^5.0.0"
glob-parent "^6.0.2"
ignore "^5.2.0"
imurmurhash "^0.1.4"
is-glob "^4.0.0"
json-stable-stringify-without-jsonify "^1.0.1"
minimatch "^10.2.5"
natural-compare "^1.4.0"
optionator "^0.9.3"
espree@^11.2.0:
version "11.2.0"
resolved "https://registry.yarnpkg.com/espree/-/espree-11.2.0.tgz#01d5e47dc332aaba3059008362454a8cc34ccaa5"
integrity sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==
dependencies:
acorn "^8.16.0"
acorn-jsx "^5.3.2"
eslint-visitor-keys "^5.0.1"
esprima@^4.0.0: esprima@^4.0.0:
version "4.0.1" version "4.0.1"
resolved "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz" resolved "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz"
integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
esquery@^1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.7.0.tgz#08d048f261f0ddedb5bae95f46809463d9c9496d"
integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==
dependencies:
estraverse "^5.1.0"
esrecurse@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921"
integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==
dependencies:
estraverse "^5.2.0"
estraverse@^5.1.0, estraverse@^5.2.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123"
integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==
esutils@^2.0.2:
version "2.0.3"
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
ethereum-blockies-base64@^1.0.2: ethereum-blockies-base64@^1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.npmjs.org/ethereum-blockies-base64/-/ethereum-blockies-base64-1.0.2.tgz" resolved "https://registry.npmjs.org/ethereum-blockies-base64/-/ethereum-blockies-base64-1.0.2.tgz"
@@ -1605,11 +1830,21 @@ expect@30.2.0:
jest-mock "30.2.0" jest-mock "30.2.0"
jest-util "30.2.0" jest-util "30.2.0"
fast-json-stable-stringify@^2.1.0: fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz" resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz"
integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
fast-levenshtein@^2.0.6:
version "2.0.6"
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==
fb-watchman@^2.0.2: fb-watchman@^2.0.2:
version "2.0.2" version "2.0.2"
resolved "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz" resolved "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz"
@@ -1617,6 +1852,13 @@ fb-watchman@^2.0.2:
dependencies: dependencies:
bser "2.1.1" bser "2.1.1"
file-entry-cache@^8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f"
integrity sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==
dependencies:
flat-cache "^4.0.0"
fill-range@^7.1.1: fill-range@^7.1.1:
version "7.1.1" version "7.1.1"
resolved "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz" resolved "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz"
@@ -1632,6 +1874,27 @@ find-up@^4.0.0, find-up@^4.1.0:
locate-path "^5.0.0" locate-path "^5.0.0"
path-exists "^4.0.0" path-exists "^4.0.0"
find-up@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc"
integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==
dependencies:
locate-path "^6.0.0"
path-exists "^4.0.0"
flat-cache@^4.0.0:
version "4.0.1"
resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-4.0.1.tgz#0ece39fcb14ee012f4b0410bd33dd9c1f011127c"
integrity sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==
dependencies:
flatted "^3.2.9"
keyv "^4.5.4"
flatted@^3.2.9:
version "3.4.4"
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.4.tgz#aeeca2a506303f0cee61c59e6c9f2a88d2f29fc6"
integrity sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==
foreground-child@^3.1.0: foreground-child@^3.1.0:
version "3.3.1" version "3.3.1"
resolved "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz" resolved "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz"
@@ -1670,6 +1933,13 @@ get-stream@^6.0.0:
resolved "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz" resolved "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz"
integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==
glob-parent@^6.0.2:
version "6.0.2"
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3"
integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==
dependencies:
is-glob "^4.0.3"
glob@^10.3.10: glob@^10.3.10:
version "10.5.0" version "10.5.0"
resolved "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz" resolved "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz"
@@ -1694,6 +1964,11 @@ glob@^7.1.4:
once "^1.3.0" once "^1.3.0"
path-is-absolute "^1.0.0" path-is-absolute "^1.0.0"
globals@17.11.0:
version "17.11.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-17.11.0.tgz#d643485bb30220d7751e511cf4f68c73d3870d87"
integrity sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==
graceful-fs@^4.2.11, graceful-fs@^4.2.4: graceful-fs@^4.2.11, graceful-fs@^4.2.4:
version "4.2.11" version "4.2.11"
resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz" resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz"
@@ -1714,6 +1989,11 @@ human-signals@^2.1.0:
resolved "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz" resolved "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz"
integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==
ignore@^5.2.0:
version "5.3.2"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5"
integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==
import-local@^3.2.0: import-local@^3.2.0:
version "3.2.0" version "3.2.0"
resolved "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz" resolved "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz"
@@ -1760,7 +2040,7 @@ is-generator-fn@^2.1.0:
resolved "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz" resolved "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz"
integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ== integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==
is-glob@^4.0.3: is-glob@^4.0.0, is-glob@^4.0.3:
version "4.0.3" version "4.0.3"
resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz" resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz"
integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
@@ -2212,21 +2492,51 @@ jsesc@^3.0.2:
resolved "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz" resolved "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz"
integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA== integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==
json-buffer@3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13"
integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==
json-parse-even-better-errors@^2.3.0: json-parse-even-better-errors@^2.3.0:
version "2.3.1" version "2.3.1"
resolved "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz" resolved "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz"
integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==
json-schema-traverse@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==
json-stable-stringify-without-jsonify@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==
json5@^2.2.3: json5@^2.2.3:
version "2.2.3" version "2.2.3"
resolved "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz" resolved "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz"
integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
keyv@^4.5.4:
version "4.5.4"
resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93"
integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==
dependencies:
json-buffer "3.0.1"
leven@^3.1.0: leven@^3.1.0:
version "3.1.0" version "3.1.0"
resolved "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz" resolved "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz"
integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A== integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==
levn@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade"
integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==
dependencies:
prelude-ls "^1.2.1"
type-check "~0.4.0"
libsodium-sumo@^0.8.0: libsodium-sumo@^0.8.0:
version "0.8.2" version "0.8.2"
resolved "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.8.2.tgz" resolved "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.8.2.tgz"
@@ -2325,6 +2635,13 @@ locate-path@^5.0.0:
dependencies: dependencies:
p-locate "^4.1.0" p-locate "^4.1.0"
locate-path@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286"
integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==
dependencies:
p-locate "^5.0.0"
lru-cache@^10.2.0: lru-cache@^10.2.0:
version "10.4.3" version "10.4.3"
resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz" resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz"
@@ -2376,6 +2693,13 @@ mimic-fn@^2.1.0:
resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz" resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz"
integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
minimatch@^10.2.4, minimatch@^10.2.5:
version "10.2.6"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.6.tgz#fd956bbe0b77241e9f15ac5dccb1c638060968ef"
integrity sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==
dependencies:
brace-expansion "^5.0.8"
minimatch@^3.0.4, minimatch@^3.1.1: minimatch@^3.0.4, minimatch@^3.1.1:
version "3.1.3" version "3.1.3"
resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz" resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz"
@@ -2456,6 +2780,18 @@ onetime@^5.1.2:
dependencies: dependencies:
mimic-fn "^2.1.0" mimic-fn "^2.1.0"
optionator@^0.9.3:
version "0.9.4"
resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734"
integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==
dependencies:
deep-is "^0.1.3"
fast-levenshtein "^2.0.6"
levn "^0.4.1"
prelude-ls "^1.2.1"
type-check "^0.4.0"
word-wrap "^1.2.5"
p-limit@^2.2.0: p-limit@^2.2.0:
version "2.3.0" version "2.3.0"
resolved "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz" resolved "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz"
@@ -2463,7 +2799,7 @@ p-limit@^2.2.0:
dependencies: dependencies:
p-try "^2.0.0" p-try "^2.0.0"
p-limit@^3.1.0: p-limit@^3.0.2, p-limit@^3.1.0:
version "3.1.0" version "3.1.0"
resolved "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz" resolved "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz"
integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==
@@ -2477,6 +2813,13 @@ p-locate@^4.1.0:
dependencies: dependencies:
p-limit "^2.2.0" p-limit "^2.2.0"
p-locate@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834"
integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==
dependencies:
p-limit "^3.0.2"
p-try@^2.0.0: p-try@^2.0.0:
version "2.2.0" version "2.2.0"
resolved "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz" resolved "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz"
@@ -2562,6 +2905,11 @@ pnglib@0.0.1:
resolved "https://registry.npmjs.org/pnglib/-/pnglib-0.0.1.tgz" resolved "https://registry.npmjs.org/pnglib/-/pnglib-0.0.1.tgz"
integrity sha512-95ChzOoYLOPIyVmL+Y6X+abKGXUJlvOVLkB1QQkyXl7Uczc6FElUy/x01NS7r2GX6GRezloO/ecCX9h4U9KadA== integrity sha512-95ChzOoYLOPIyVmL+Y6X+abKGXUJlvOVLkB1QQkyXl7Uczc6FElUy/x01NS7r2GX6GRezloO/ecCX9h4U9KadA==
prelude-ls@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
prettier@^3.8.1: prettier@^3.8.1:
version "3.8.1" version "3.8.1"
resolved "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz" resolved "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz"
@@ -2576,6 +2924,11 @@ pretty-format@30.2.0:
ansi-styles "^5.2.0" ansi-styles "^5.2.0"
react-is "^18.3.1" react-is "^18.3.1"
punycode@^2.1.0:
version "2.3.1"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
pure-rand@^7.0.0: pure-rand@^7.0.0:
version "7.0.1" version "7.0.1"
resolved "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz" resolved "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz"
@@ -2822,6 +3175,13 @@ tslib@^2.8.1:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
type-check@^0.4.0, type-check@~0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"
integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==
dependencies:
prelude-ls "^1.2.1"
type-detect@4.0.8: type-detect@4.0.8:
version "4.0.8" version "4.0.8"
resolved "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz" resolved "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz"
@@ -2872,6 +3232,13 @@ update-browserslist-db@^1.2.0:
escalade "^3.2.0" escalade "^3.2.0"
picocolors "^1.1.1" picocolors "^1.1.1"
uri-js@^4.2.2:
version "4.4.1"
resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e"
integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==
dependencies:
punycode "^2.1.0"
v8-to-istanbul@^9.0.1: v8-to-istanbul@^9.0.1:
version "9.3.0" version "9.3.0"
resolved "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz" resolved "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz"
@@ -2900,6 +3267,11 @@ which@^2.0.1:
dependencies: dependencies:
isexe "^2.0.0" isexe "^2.0.0"
word-wrap@^1.2.5:
version "1.2.5"
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0" version "7.0.0"
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz"