Compare commits
11 Commits
7d97ea4b53
...
next
| Author | SHA1 | Date | |
|---|---|---|---|
| ff3387d8cf | |||
| 8fcdd8a053 | |||
| 7690fe6429 | |||
| a60c4a616a | |||
| 47bf38644d | |||
| 4b7a678a9b | |||
| ab1c1846a7 | |||
| 743b1962a5 | |||
| c06765ef8f | |||
| e07efb710a | |||
| d9d50f05d2 |
49
.gitea/workflows/e2e.yml
Normal file
49
.gitea/workflows/e2e.yml
Normal 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
|
||||
27
Dockerfile
27
Dockerfile
@@ -1,8 +1,21 @@
|
||||
# node:22-slim (22.x LTS), 2026-02-24
|
||||
FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36
|
||||
FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36 AS base
|
||||
|
||||
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
|
||||
# is already in the base image, yarn comes via corepack) and runs
|
||||
# yarn install --frozen-lockfile. Dependency manifests are copied first
|
||||
@@ -13,5 +26,17 @@ RUN script/bootstrap
|
||||
|
||||
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 build
|
||||
|
||||
9
LICENSE
9
LICENSE
@@ -682,7 +682,14 @@ under their own licenses. They are NOT covered by the GPL-3.0 license above.
|
||||
|
||||
---------------------------------------------------------------------------
|
||||
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
|
||||
License: Don't Be a Dick Public License (DBAD), Version 1.2
|
||||
---------------------------------------------------------------------------
|
||||
|
||||
16
Makefile
16
Makefile
@@ -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/
|
||||
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
||||
@@ -35,6 +35,12 @@ fmt-check:
|
||||
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:
|
||||
@script/docker
|
||||
|
||||
@@ -45,6 +51,7 @@ build:
|
||||
@echo "Building extension..."
|
||||
@yarn run build 2>&1
|
||||
@script/verify-build
|
||||
@script/check-censored --require-dist
|
||||
|
||||
# Development-only build: enables the red DEBUG / INSECURE banner and makes
|
||||
# the hardcoded test recovery phrase the output of wallet creation. Never
|
||||
@@ -53,12 +60,19 @@ build-debug:
|
||||
@echo "Building extension (DEBUG)..."
|
||||
@AUTISTMASK_DEBUG=1 yarn run build 2>&1
|
||||
@AUTISTMASK_DEBUG=1 script/verify-build
|
||||
@script/check-censored --require-dist
|
||||
|
||||
# Assert the compiled DEBUG state of the bundles already in dist/. Runs at
|
||||
# the end of build and build-debug; separate target for re-running it alone.
|
||||
verify-build:
|
||||
@script/verify-build
|
||||
|
||||
# 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:
|
||||
@rm -rf dist/
|
||||
|
||||
|
||||
437
README.md
437
README.md
@@ -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
|
||||
blockchain interactions, a public CoinDesk API (no API key) for realtime price
|
||||
information, and a Blockscout block-explorer API for transaction history and
|
||||
token balances. It also fetches a community-maintained phishing domain blocklist
|
||||
periodically and performs best-effort Etherscan address label lookups during
|
||||
transaction confirmation.
|
||||
token balances. It also performs best-effort Etherscan address label lookups
|
||||
during transaction confirmation. A community-maintained phishing domain
|
||||
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
|
||||
can add any ERC20 contract by contract address if you wish, but the hardcoded
|
||||
@@ -83,15 +84,36 @@ provide:
|
||||
git pre-commit hook
|
||||
- `script/projectname` — print the project name (used for the Docker image tag)
|
||||
- `script/test` — run the test suite (jest)
|
||||
- `script/test-e2e` — run the Chrome browser end-to-end suite (docker required;
|
||||
see [End-to-End Tests](#end-to-end-tests))
|
||||
- `script/test-e2e-firefox` — run the Firefox browser end-to-end suite (docker
|
||||
required; builds its own pinned image, see
|
||||
- `script/test-e2e` — run the Chrome browser end-to-end suite (docker is the
|
||||
only prerequisite: it builds a pinned image that carries the repo and a fresh
|
||||
extension build, see [End-to-End Tests](#end-to-end-tests))
|
||||
- `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))
|
||||
- `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-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
|
||||
`dist/`: every bundle containing `src/shared/constants.js` must have `DEBUG`
|
||||
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
|
||||
Chrome**, loaded as an unpacked MV3 extension inside a pinned
|
||||
`mcr.microsoft.com/playwright` container (pinned by digest in `script/test-e2e`;
|
||||
docker is required and the suite fails loudly rather than skipping if it is
|
||||
unavailable). The suite lives in `tests/e2e/` and is driven by
|
||||
`playwright-core`, whose version must stay matched to the container's Playwright
|
||||
version — the browsers ship inside the image.
|
||||
`mcr.microsoft.com/playwright` container (pinned by digest in
|
||||
`tests/e2e/Dockerfile`, which is also where the extension is built; docker is
|
||||
required and the suite fails loudly rather than skipping if it is unavailable).
|
||||
The suite lives in `tests/e2e/` and is driven by `playwright-core`, whose
|
||||
version must stay matched to the container's Playwright version — the browsers
|
||||
ship inside the image.
|
||||
|
||||
It covers popup load, WebAssembly compilation under the shipped CSP (see
|
||||
[Content Security Policy](#content-security-policy)), wallet creation through
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
`PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1` for it. Because that flag is
|
||||
experimental, the harness does not take it on trust. At launch it waits for the
|
||||
background worker's **own** startup request — the phishing blocklist fetch that
|
||||
`src/background/index.js` issues on startup, which on the suite's throwaway
|
||||
profile always happens because no previous fetch timestamp is persisted — to
|
||||
arrive in the route handler, and aborts the entire suite if none does within 30
|
||||
seconds (`tests/e2e/harness.js`). The check is passive on purpose: a synthetic
|
||||
probe fetched from inside the worker via `worker.evaluate()` was tried first and
|
||||
rejected, because evaluating in an extension service worker that early kills the
|
||||
worker outright, destroying the thing being measured. Observing traffic the
|
||||
extension already generates perturbs nothing. Losing the race fails closed — the
|
||||
suite refuses to run rather than passing quietly.
|
||||
background worker to exist, asks it for one throwaway `fetch()` of its own, and
|
||||
requires that request to arrive in the route handler within 30 seconds or aborts
|
||||
the entire suite (`tests/e2e/harness.js`). The anchor used to be the worker's
|
||||
own startup traffic — the phishing blocklist fetch — and there is no longer any:
|
||||
the blocklist is vendored at build time and the extension contacts nobody when
|
||||
it starts. An earlier synthetic probe was rejected because evaluating in an
|
||||
extension service worker immediately after launch killed the worker outright;
|
||||
waiting for the worker to be handed over first, and issuing a `fetch()` that is
|
||||
not awaited, does not. Failing the probe fails closed — the suite refuses to run
|
||||
rather than passing quietly.
|
||||
|
||||
As defence in depth, Chrome is also started with
|
||||
`--host-resolver-rules=MAP * ~NOTFOUND`, so a request that ever did slip past
|
||||
@@ -232,24 +272,37 @@ being intercepted, run with `E2E_TRACE_NETWORK=1` and every routed request is
|
||||
printed, tagged `[sw]` or `[page]`.
|
||||
|
||||
**Any uncaught page error or `console.error` fails the run.** That is the point:
|
||||
a `ReferenceError` from a used-but-not-imported identifier is invisible to
|
||||
`make check` (`script/lint` is only `prettier --check`) but fatal in a browser,
|
||||
and this suite exists because exactly that class of bug shipped twice.
|
||||
this suite exists because a `ReferenceError` from a used-but-not-imported
|
||||
identifier shipped twice, fatal in a browser and invisible to a `make check`
|
||||
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`)
|
||||
|
||||
`make test-e2e-firefox` builds `dist/firefox/` and drives the **real popup in a
|
||||
real Firefox**, installed as an unpacked MV2 temporary add-on via geckodriver.
|
||||
It covers popup load, wallet creation through the UI, and the Add Token screen.
|
||||
The suite lives in `tests/e2e/firefox/` and has **no npm dependencies at all**:
|
||||
it is a small WebDriver client built on global `fetch` and `child_process`
|
||||
against geckodriver's HTTP API.
|
||||
It covers popup load, wallet creation through the UI, the Add Token screen, and
|
||||
the four dApp round trips — `eth_requestAccounts`, `personal_sign`,
|
||||
`eth_sendTransaction`, and a closed approval window rejecting with EIP-1193 4001
|
||||
— 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
|
||||
published one, because no published image carries both a pinned Firefox and a
|
||||
matching geckodriver. `tests/e2e/firefox/Dockerfile` pins all three external
|
||||
artifacts by digest — the `node` base image, the Firefox 153.0.3 tarball, and
|
||||
geckodriver 0.36.0 — and the Firefox version in particular must not float:
|
||||
The suite lives in `tests/e2e/firefox/`. Its WebDriver client (`driver.js`) has
|
||||
**no npm dependencies at all**: it is built on global `fetch` and
|
||||
`child_process` against geckodriver's HTTP API. The dApp fixture (`dapp.js`) and
|
||||
the assertions do use `ethers`, and have to — a signature is recovered in the
|
||||
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.
|
||||
Without that flag, both navigating to `moz-extension://` and running
|
||||
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
|
||||
errors from the background page, which the suite never navigates to: a `throw`
|
||||
at the top of `src/background/index.js` kills the background page and fails
|
||||
step 1. Content-script errors should arrive by the same route, but this suite
|
||||
does not exercise it and does not claim it — with `--network none` there is no
|
||||
`http://` page for a content script to be injected into. Errors from add-on
|
||||
install and background startup are folded into step 1 rather than discarded.
|
||||
Errors are read from the privileged `nsIConsoleService` in Marionette's chrome
|
||||
context and filtered to non-warning entries whose `sourceName` is the extension
|
||||
origin. That mechanism is not a stylistic choice. WebDriver BiDi's
|
||||
`log.entryAdded` delivers **nothing** for extension pages: on a plain `http://`
|
||||
page it reports uncaught errors with stack traces, and on the `moz-extension://`
|
||||
popup it reports zero events, because Firefox's remote agent excludes extension
|
||||
browsing contexts from BiDi observation. Any harness built on Playwright-BiDi or
|
||||
Puppeteer-BiDi would therefore see nothing and report success, which is exactly
|
||||
the vacuous check this repo has already shipped twice. Do not migrate this suite
|
||||
to BiDi.
|
||||
step 1. Content scripts **are** exercised now — the dApp steps drive a page
|
||||
served from loopback, which survives `--network none` — but the _capture_ of a
|
||||
content-script error by this route is still unproven: no probe has forced a
|
||||
throw inside one and watched it fail the run, so it remains an expectation
|
||||
rather than a demonstrated fact. Errors from add-on install and background
|
||||
startup are folded into step 1 rather than discarded.
|
||||
|
||||
An **unhandled promise rejection counts as an uncaught error** on both suites,
|
||||
which matters because a good deal of popup code is now `async` and called
|
||||
without an `await`. Demonstrated, not assumed: a `throw` placed past the first
|
||||
`await` of `approval.show()` — which nothing awaits — turns the
|
||||
`eth_requestAccounts` step red on Firefox
|
||||
(`uncaught extension errors during this step`, from the console-service drain)
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
is location, not coverage: an error cannot be placed within a step the way the
|
||||
Chrome suite's `pageerror` events place it.
|
||||
- **Nothing is stubbed, which inverts the coverage of network-dependent code.**
|
||||
There is no fixture layer; the container runs with `--network none` instead,
|
||||
so the run is offline and deterministic and no request can escape. The
|
||||
extension swallows its own fetch failures, so the flows are unaffected — but
|
||||
every network call fails, so only the _failure_ branches of code that depends
|
||||
on one are ever executed. A `ReferenceError` in the success path of
|
||||
`renderTransactions`, or of price or balance rendering, passes this suite
|
||||
green. The offline run is also weaker than the Chrome suite's interception: it
|
||||
proves nothing got out, but it cannot report which requests were attempted.
|
||||
Closing that gap needs a fixture layer, deliberately out of scope for this
|
||||
harness.
|
||||
- **Almost nothing is stubbed, which inverts the coverage of network-dependent
|
||||
code.** The container still runs with `--network none`, so the run is offline
|
||||
and no request can escape. The one thing it can reach is the loopback fixture
|
||||
in `tests/e2e/firefox/dapp.js`, which serves the dApp page and a JSON-RPC node
|
||||
and which the extension's `rpcUrl` is pointed at for the dApp steps; a
|
||||
JSON-RPC method that fixture does not model fails the run rather than
|
||||
answering `null`. Everything else — Blockscout, the price feed, the phishing
|
||||
blocklist — has no fixture and simply fails, and the extension swallows its
|
||||
own fetch failures, so only the _failure_ branches of that code are ever
|
||||
executed. A `ReferenceError` in the success path of `renderTransactions`, or
|
||||
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
|
||||
`make test`. `REPO_POLICIES.md` caps `make test` at 20 seconds and a browser
|
||||
suite does not fit; nothing in `tests/e2e/` is named `*.test.js`, so jest cannot
|
||||
pick it up either. Neither is wired into the Gitea workflow yet —
|
||||
docker-in-docker in CI is a separate question. Run them locally before changing
|
||||
anything under `src/popup/views/`.
|
||||
pick it up either. Run them locally before changing 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
|
||||
|
||||
@@ -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
|
||||
worker, so a job scheduled that way runs until the first idle period and never
|
||||
again. Both recurring jobs — the 60-second balance refresh and the 24-hour
|
||||
phishing blocklist refresh — are scheduled through the extension alarms API
|
||||
(`src/shared/alarms.js`) instead. The browser holds the schedule and wakes the
|
||||
worker to deliver it. Alarm periods are clamped to a one-minute minimum, so
|
||||
the balance refresh is expressed as exactly one minute and nothing is silently
|
||||
slowed down.
|
||||
again. The one recurring job — the 60-second balance refresh — is scheduled
|
||||
through the extension alarms API (`src/shared/alarms.js`) instead. The browser
|
||||
holds the schedule and wakes the worker to deliver it. Alarm periods are
|
||||
clamped to a one-minute minimum, so the balance refresh is expressed as
|
||||
exactly one minute and nothing is silently slowed down.
|
||||
- Module-level variables do not survive either. Anything that must be remembered
|
||||
across a restart goes in extension storage, including the timestamp of the
|
||||
last phishing list fetch: without it a revived worker would either re-fetch on
|
||||
every wake or, with a naive in-memory guard, never notice that an update is
|
||||
due. `localStorage` does not exist in a service worker at all — the one
|
||||
remaining user of it, `src/shared/ens.js`, runs only in the popup and is
|
||||
marked as such.
|
||||
across a restart goes in extension storage. `localStorage` does not exist in a
|
||||
service worker at all — the one remaining user of it, `src/shared/ens.js`,
|
||||
runs only in the popup and is marked as such.
|
||||
|
||||
Both jobs also carry a freshness guard, and a guard must never be timed to the
|
||||
alarm period it gates. Each guard is measured from the moment the last run
|
||||
The job also carries a freshness guard, and a guard must never be timed to the
|
||||
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
|
||||
of exactly one period vetoes the very next tick and the real cadence becomes two
|
||||
periods. The two jobs solve this differently, because their guards exist for
|
||||
different reasons:
|
||||
|
||||
- The phishing refresh has a 24-hour cache TTL whose job is to keep the worker
|
||||
off the network on the wakes between scheduled refreshes — Chrome revives the
|
||||
worker every ~30 seconds while the browser is busy, and every revival runs the
|
||||
startup path. The scheduled alarm tick is not one of those wakes, so it
|
||||
bypasses the TTL and fetches unconditionally. Shortening the TTL instead would
|
||||
not work: the startup path re-checks it on every wake, so a shorter TTL simply
|
||||
becomes the real refresh rate.
|
||||
- The balance refresh guard exists to skip work an open popup has already done —
|
||||
the popup refreshes every 10 seconds and stamps the same field. That has to
|
||||
keep applying on the scheduled tick, so the guard is shortened to half the
|
||||
alarm period instead of bypassed: comfortably above the popup's 10 seconds, so
|
||||
an open popup still suppresses the background job, and comfortably below the
|
||||
periods. 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 — 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.
|
||||
|
||||
Two timestamps are persisted for the phishing list, not one. `lastFetchTime`
|
||||
records a fetch that produced a usable delta and drives the TTL.
|
||||
`lastAttemptTime` records that the network was contacted at all, and is written
|
||||
even when the result is unusable — a failed request, or a delta over the 256 KiB
|
||||
cap. Without it those cases leave no freshness mark and the worker re-downloads
|
||||
the full blocklist on every wake, indefinitely; with it, unscheduled retries are
|
||||
floored at one hour. Both are discarded on load if they are in the future, since
|
||||
a stamp from a skewed clock or a restored backup would otherwise suppress
|
||||
updates until that time arrives, permanently and with no way out.
|
||||
Retiring a job means clearing its alarm, not just deleting its handler. The
|
||||
browser keeps an alarm until something removes it, so an install that once ran
|
||||
the version which created it goes on being woken on that schedule forever. Names
|
||||
that are no longer handled are listed in `OBSOLETE_ALARMS` and cleared on every
|
||||
start; the 24-hour phishing blocklist refresh is there, retired when the runtime
|
||||
fetch was removed.
|
||||
|
||||
The startup path (`ensureRecurringAlarms()` plus the phishing list init) runs on
|
||||
`onInstalled`, on `onStartup`, and at the top level of the worker, so every way
|
||||
the background context can start re-establishes the schedule. On a fresh install
|
||||
more than one of those fires, so they share a single in-flight run rather than
|
||||
racing. It is idempotent: an alarm that already exists with the period the code
|
||||
asks for is left alone, because re-creating one restarts its schedule and a busy
|
||||
extension would push the next fire out indefinitely. An alarm carrying a
|
||||
different period — one created by an earlier version — is re-created once, or a
|
||||
period changed in a new release would never reach an existing install.
|
||||
The startup path (`ensureRecurringAlarms()`) runs on `onInstalled`, on
|
||||
`onStartup`, and at the top level of the worker, so every way the background
|
||||
context can start re-establishes the schedule. On a fresh install more than one
|
||||
of those fires, so they share a single in-flight run rather than racing. It is
|
||||
idempotent: an alarm that already exists with the period the code asks for is
|
||||
left alone, because re-creating one restarts its schedule and a busy extension
|
||||
would push the next fire out indefinitely. An alarm carrying a different period
|
||||
— one created by an earlier version — is re-created once, or a period changed in
|
||||
a new release would never reach an existing install.
|
||||
|
||||
Nothing is fetched when the worker starts. A wake costs no network traffic at
|
||||
all, which is what the phishing blocklist being vendored at build time bought.
|
||||
|
||||
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,
|
||||
@@ -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
|
||||
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
|
||||
|
||||
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.
|
||||
- **Elements**:
|
||||
- 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)
|
||||
- Send / Receive quick-action buttons, both acting on the active address
|
||||
- ETH/USD price display
|
||||
@@ -735,7 +873,7 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
|
||||
- Title: "Wallet Name — Address N"
|
||||
- ENS name (if resolved, bold above the address)
|
||||
- Full address (color dot, etherscan link, tap to copy)
|
||||
- USD total for address
|
||||
- USD total for address (see [Partial USD totals](#partial-usd-totals))
|
||||
- Balance list: ETH + the ERC-20 tokens shown for this address (4 decimal
|
||||
places, USD inline). Each balance row is clickable → **AddressToken**
|
||||
- Send / Receive / + Token buttons and a "···" menu button
|
||||
@@ -1101,11 +1239,13 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
|
||||
xprv wallet has no recovery phrase to re-import.
|
||||
- A warning when the address holds anything, ETH or any tracked ERC-20,
|
||||
followed by the holdings themselves via `balanceLinesForAddress()` and the
|
||||
USD total via `getAddressValueUsd()`. The sentence names no figure of its
|
||||
own: the lines round to four decimals, so a sentence built from a rounded
|
||||
number would report `0.0000 ETH` for an address holding real money. The
|
||||
predicate is `addressHoldsFunds()` in `src/popup/views/helpers.js`,
|
||||
unrounded and token-aware. A balance is a warning, never a refusal.
|
||||
USD total via `formatAddressTotal()` (see
|
||||
[Partial USD totals](#partial-usd-totals)). The sentence names no figure
|
||||
of its own: the lines round to four decimals, so a sentence built from a
|
||||
rounded number would report `0.0000 ETH` for an address holding real
|
||||
money. The predicate is `addressHoldsFunds()` in
|
||||
`src/popup/views/helpers.js`, unrounded and token-aware. A balance is a
|
||||
warning, never a refusal.
|
||||
- The rule that a wallet always keeps at least one address, and that
|
||||
removing the last one means deleting the wallet from Settings
|
||||
- Error line
|
||||
@@ -1175,7 +1315,11 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
|
||||
opening the window, so the screen shows a complete transaction and the signed
|
||||
artifact can be compared with it field for field. A request that cannot be
|
||||
populated — unreachable node, reverting gas estimate — opens no window and is
|
||||
failed back to the site.
|
||||
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**:
|
||||
- "Transaction Request" heading
|
||||
- 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,
|
||||
CoinDesk price API, and Blockscout API), AutistMask also contacts:
|
||||
|
||||
- **Phishing domain blocklist**: A community-maintained phishing domain
|
||||
blocklist is vendored into the extension at build time. At runtime, the
|
||||
extension fetches the live list once every 24 hours to detect newly added
|
||||
domains, plus once on a start where the list is more than 24 hours old. Only
|
||||
the delta (domains not already in the vendored list) is kept in memory,
|
||||
keeping runtime memory usage small. The delta and the timestamp of the fetch
|
||||
that produced it are persisted to extension storage if the record is under 256
|
||||
KiB; an oversized delta is dropped along with its timestamp, so a later start
|
||||
fetches again rather than claiming freshness for data it no longer holds. A
|
||||
fetch that fails, or one whose delta was too large to store, is not retried
|
||||
more than once an hour outside the 24-hour schedule.
|
||||
- **Etherscan address labels**: When confirming a transaction, the extension
|
||||
performs a best-effort lookup of the recipient address on Etherscan to check
|
||||
for phishing/scam labels. This is a direct page fetch with no API key; the
|
||||
@@ -1550,17 +1683,25 @@ indexes it as a real token transfer.
|
||||
|
||||
AutistMask protects users from known phishing sites when they connect their
|
||||
wallet or approve transactions/signatures. A community-maintained domain
|
||||
blocklist is vendored into the extension at build time, providing immediate
|
||||
protection without any network requests. At runtime, the extension fetches the
|
||||
live list once every 24 hours and keeps only the delta (newly added domains not
|
||||
in the vendored list) in memory. This architecture keeps runtime memory usage
|
||||
small while ensuring fresh coverage of new phishing domains.
|
||||
blocklist is vendored into the extension at build time and checked entirely
|
||||
locally: no network request is made for it, ever, so nobody learns which sites
|
||||
the user connects to and no third party decides what this wallet warns about.
|
||||
|
||||
The 24-hour cadence is an alarm, not a timer; the alarm tick fetches
|
||||
unconditionally rather than re-checking the 24-hour cache TTL that gates the
|
||||
startup path; and the fetch timestamps live in extension storage rather than in
|
||||
module variables — see [Background scheduling](#background-scheduling) for why
|
||||
all three are required.
|
||||
The trade is freshness. The shipped list is exactly as current as the last
|
||||
vendoring run that was released, so a domain added upstream reaches users in the
|
||||
next release rather than within a day. Refreshing it is `make vendor-blocklist`,
|
||||
which fetches a hash-pinned upstream commit, verifies the sha256 of the bytes it
|
||||
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
|
||||
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:
|
||||
|
||||
| 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 EtherScamDB) | [EtherScamDB](https://github.com/MrLuit/EtherScamDB) `scams.yaml` | Copyright (c) 2018 Luit Hollander | MIT |
|
||||
|
||||
The full license texts for these third-party files are included in the
|
||||
[LICENSE](LICENSE) file. The `eth-phishing-detect` row carries no repository
|
||||
link because the upstream is hosted under a competitor's organization name,
|
||||
which project policy keeps out of code and documentation; the vendored copy and
|
||||
the runtime refresh both come from that upstream, whose URL is the
|
||||
`BLOCKLIST_URL` constant in `src/shared/phishingDomains.js`.
|
||||
which project policy keeps out of code and documentation.
|
||||
`script/vendor-blocklist` is the single definition and the only file that spells
|
||||
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
|
||||
|
||||
|
||||
228
TODO.md
228
TODO.md
@@ -32,19 +32,117 @@ The backlog lives on the
|
||||
[Gitea tracker](https://git.eeqj.de/sneak/AutistMask/issues), which is
|
||||
authoritative; this file does not duplicate it. Full policy file set present.
|
||||
Real-browser end-to-end suites (`make test-e2e` for Chrome,
|
||||
`make test-e2e-firefox` for Firefox) now sit alongside `make check`, which
|
||||
cannot see a runtime `ReferenceError` in a popup view.
|
||||
`make test-e2e-firefox` for Firefox) sit alongside `make check`, which now does
|
||||
static analysis as well as formatting, and `.gitea/workflows/e2e.yml` runs both
|
||||
of them on every push.
|
||||
|
||||
# Next Step
|
||||
|
||||
Land [#152](https://git.eeqj.de/sneak/AutistMask/issues/152): add ESLint to
|
||||
`script/lint`. `make check` is `prettier --check` only today and cannot catch
|
||||
undefined identifiers, which is how
|
||||
[#150](https://git.eeqj.de/sneak/AutistMask/issues/150) and
|
||||
[#151](https://git.eeqj.de/sneak/AutistMask/issues/151) shipped.
|
||||
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.
|
||||
|
||||
# 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
|
||||
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-
|
||||
wordings split that [#172](https://git.eeqj.de/sneak/AutistMask/issues/172)
|
||||
closed for a rejected password. Strings only, no behaviour change.
|
||||
`tests/passwordMessages.test.js` now pins the empty-field guard per call site
|
||||
as well as the decrypt handler, anchored on the `decryptWithPassword` sites so
|
||||
the wallet-creation screen — where an empty field means a password being
|
||||
chosen, a different condition — stays out of the set. Every error container
|
||||
measured at a 360px viewport in the pinned Playwright container: the export
|
||||
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
|
||||
([#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
|
||||
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
|
||||
@@ -54,6 +152,115 @@ undefined identifiers, which is how
|
||||
stubbed RPC and the unobservable toolbar popup — were checked against the
|
||||
current `src/content/inpage.js` and `tests/e2e/` and left as they are
|
||||
([#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
|
||||
[#150](https://git.eeqj.de/sneak/AutistMask/issues/150) and
|
||||
[#151](https://git.eeqj.de/sneak/AutistMask/issues/151) definition of done the
|
||||
e2e suite did not cover are asserted. It had only shown that the two screens
|
||||
open without throwing. Now: the Add Token round trip leaves the navigation
|
||||
stack exactly as it found it, read out of extension storage rather than
|
||||
inferred from which screen is up, so an orphaned entry — the second-order
|
||||
damage of #150 — is caught where it happens rather than one Back press later;
|
||||
a common-token quick-pick puts its contract address in the field; the native
|
||||
ETH detail path renders with its own type, value and raw quantity and with the
|
||||
token contract row still hidden, against a new `seedNativeTransfer` fixture,
|
||||
since the normal-transactions endpoint answered `[]` unconditionally and there
|
||||
was no non-ERC-20 row to open; and tapping the token contract address puts it
|
||||
on the real clipboard, read back after a sentinel write. Each of the four was
|
||||
demonstrated failing against a deliberately broken build
|
||||
([#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`
|
||||
rebuilt every failure as `new Error(error.message)`, so the code the
|
||||
background produced and the content script relayed intact was dropped in the
|
||||
@@ -374,12 +581,5 @@ undefined identifiers, which is how
|
||||
Only work that has no issue of its own belongs here; everything else is on the
|
||||
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
|
||||
land.
|
||||
|
||||
4
build.js
4
build.js
@@ -63,7 +63,7 @@ function getBuildInfo() {
|
||||
commitHash = execSync("git rev-parse --short HEAD", {
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
} catch (_) {
|
||||
} catch {
|
||||
// not a git repo or git not available
|
||||
}
|
||||
let commitHashFull = "unknown";
|
||||
@@ -71,7 +71,7 @@ function getBuildInfo() {
|
||||
commitHashFull = execSync("git rev-parse HEAD", {
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
} catch (_) {
|
||||
} catch {
|
||||
// not a git repo or git not available
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -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
|
||||
address.
|
||||
|
||||
**Phishing domain blocklist** (`raw.githubusercontent.com`)
|
||||
|
||||
A community-maintained list of phishing domains, used to warn you when a site
|
||||
that asks to connect, or to have a transaction or signature approved, is a known
|
||||
scam. A copy is bundled into the extension at build time, so the protection
|
||||
works before any network request happens. At runtime the extension fetches the
|
||||
live list to pick up newly added domains, keeping only the entries not already
|
||||
in the bundled copy (persisted locally if under 256 KiB). This endpoint is not
|
||||
user-configurable.
|
||||
|
||||
When it is contacted: when the background script starts, if the last fetch was
|
||||
more than 24 hours ago, and every 24 hours after that. The time of the last
|
||||
fetch is remembered across browser and background restarts, so restarting does
|
||||
not cause a re-download. If a fetch fails, or the list is too large to keep, the
|
||||
extension waits an hour before trying again outside that 24-hour schedule rather
|
||||
than retrying on every restart. It is a plain download of a public file —
|
||||
nothing about you is sent, but the host sees your IP address. If the fetch
|
||||
fails, the bundled copy is still used.
|
||||
|
||||
**Etherscan address labels** (`etherscan.io`; `sepolia.etherscan.io` on Sepolia)
|
||||
|
||||
When you review a send, AutistMask fetches the recipient's public Etherscan
|
||||
@@ -367,8 +348,12 @@ confirmation screen. It contains only addresses involved in fraud -- it is not a
|
||||
sanctions list.
|
||||
|
||||
**Phishing domain warnings.** Sites asking to connect or to have something
|
||||
approved are checked against the phishing domain blocklist described under
|
||||
External Services, and flagged with a red banner if they match.
|
||||
approved are checked against a community-maintained list of known phishing
|
||||
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
|
||||
see everything unfiltered.
|
||||
|
||||
162
eslint.config.js
Normal file
162
eslint.config.js
Normal 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 },
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -9,13 +9,16 @@
|
||||
"test": "jest --forceExit",
|
||||
"test:verbose": "jest --forceExit --verbose",
|
||||
"build": "node build.js",
|
||||
"lint": "prettier --check .",
|
||||
"lint": "eslint . && prettier --check .",
|
||||
"fmt": "prettier --write .",
|
||||
"fmt-check": "prettier --check ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "10.0.1",
|
||||
"@tailwindcss/cli": "^4.2.1",
|
||||
"esbuild": "^0.27.3",
|
||||
"eslint": "10.8.1",
|
||||
"globals": "17.11.0",
|
||||
"jest": "^30.2.0",
|
||||
"playwright-core": "1.56.0",
|
||||
"prettier": "^3.8.1",
|
||||
|
||||
@@ -8,6 +8,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
main() {
|
||||
"$SCRIPT_DIR/test"
|
||||
"$SCRIPT_DIR/test-verify-build"
|
||||
"$SCRIPT_DIR/check-censored"
|
||||
"$SCRIPT_DIR/lint"
|
||||
"$SCRIPT_DIR/fmt-check"
|
||||
}
|
||||
|
||||
301
script/check-censored
Executable file
301
script/check-censored
Executable 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 "$@"
|
||||
147
script/lib/build-blocklist.js
Normal file
147
script/lib/build-blocklist.js
Normal 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));
|
||||
40
script/lint
40
script/lint
@@ -1,13 +1,51 @@
|
||||
#!/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
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
|
||||
case "${AUTISTMASK_LINT_NATIVE:-}" in
|
||||
1)
|
||||
echo "Linting..."
|
||||
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 "$@"
|
||||
|
||||
38
script/test
38
script/test
@@ -1,19 +1,49 @@
|
||||
#!/bin/sh
|
||||
# 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
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
TIMEOUT="${AUTISTMASK_TEST_TIMEOUT:-30}"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
echo "Running tests..."
|
||||
timeout 30 yarn run test 2>&1 || {
|
||||
echo "Running tests (timeout ${TIMEOUT}s)..."
|
||||
|
||||
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 ---"
|
||||
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
|
||||
# flaky pass on the rerun must not turn the build green.
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
@@ -5,19 +5,32 @@
|
||||
#
|
||||
# Deliberately NOT called by script/check or script/test: REPO_POLICIES.md
|
||||
# caps make test at 20 seconds and a browser suite does not fit. Run it
|
||||
# yourself before touching popup views; it is the only check that can see
|
||||
# a used-but-not-imported identifier blow up at runtime.
|
||||
# yourself before touching popup views. ESLint's no-undef now catches a
|
||||
# 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
|
||||
|
||||
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
|
||||
#
|
||||
# The playwright-core devDependency is pinned to the matching Playwright
|
||||
# version (1.56.0) and the two must be bumped together: the browsers ship
|
||||
# inside this image, and playwright-core looks for the exact browser
|
||||
# revision its own version expects. A mismatch fails at launch.
|
||||
IMAGE="mcr.microsoft.com/playwright@sha256:35246d87a7c88ea9b771c65d33171b2611b02a8253b4b12ce6f94376c55f99f2"
|
||||
IMAGE="$("$SCRIPT_DIR/projectname")-e2e-chrome"
|
||||
|
||||
IIDFILE=""
|
||||
|
||||
cleanup() {
|
||||
if [ -n "$IIDFILE" ]; then
|
||||
rm -f "$IIDFILE"
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
@@ -27,22 +40,31 @@ main() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Building extension for e2e..."
|
||||
yarn run build 2>&1
|
||||
IIDFILE="$(mktemp)"
|
||||
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..."
|
||||
# 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
|
||||
# 64MB /dev/shm or renderers crash.
|
||||
# --user: keep files the suite touches owned by the caller, not root.
|
||||
# HOME=/tmp: the mapped uid has no home directory in the image.
|
||||
# HOME=/tmp: the image's root home is not a reliable place for the
|
||||
# browser profile.
|
||||
# PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1: without it,
|
||||
# ctx.route() intercepts page requests only, and every fetch made by
|
||||
# the MV3 background service worker — including the phishing
|
||||
# blocklist fetch that src/background/index.js issues at worker
|
||||
# startup — goes to the real internet. The flag is experimental and
|
||||
# Playwright may drop or rename it. It cannot break silently: the
|
||||
# harness probes service-worker interception at launch and aborts
|
||||
# the whole suite if it is not in effect (see the interception
|
||||
# the MV3 background service worker — the JSON-RPC calls behind
|
||||
# every approval the suite drives among them — goes to the real
|
||||
# internet. The flag is experimental and Playwright may drop or
|
||||
# rename it. It cannot break silently: the harness asks the worker
|
||||
# for one request of its own at launch and aborts the whole suite
|
||||
# if it does not reach the route handler (see the interception
|
||||
# canary in tests/e2e/harness.js). If a future Playwright removes
|
||||
# the flag, that probe is what will fail, and the fix is either a
|
||||
# replacement mechanism or an honest downgrade of the isolation
|
||||
@@ -51,13 +73,10 @@ main() {
|
||||
# on a deliberate bump.
|
||||
docker run --rm \
|
||||
--ipc=host \
|
||||
--user "$(id -u):$(id -g)" \
|
||||
-e HOME=/tmp \
|
||||
-e PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1 \
|
||||
-e "E2E_TRACE_NETWORK=${E2E_TRACE_NETWORK:-0}" \
|
||||
-v "$ROOT:/work" \
|
||||
-w /work \
|
||||
"$IMAGE" \
|
||||
"$(cat "$IIDFILE")" \
|
||||
node tests/e2e/run.js
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,17 @@
|
||||
#
|
||||
# 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
|
||||
# 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.
|
||||
# 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
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
@@ -18,6 +23,14 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||
|
||||
IMAGE="$("$SCRIPT_DIR/projectname")-e2e-firefox"
|
||||
|
||||
IIDFILE=""
|
||||
|
||||
cleanup() {
|
||||
if [ -n "$IIDFILE" ]; then
|
||||
rm -f "$IIDFILE"
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
|
||||
@@ -26,16 +39,20 @@ main() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Building extension for e2e..."
|
||||
yarn run build 2>&1
|
||||
IIDFILE="$(mktemp)"
|
||||
trap cleanup EXIT
|
||||
trap 'cleanup; exit 130' INT TERM
|
||||
|
||||
# The build context is tests/e2e/firefox/ and holds nothing but the
|
||||
# Dockerfile: the harness itself arrives over the bind mount below, so
|
||||
# editing it never invalidates an image layer.
|
||||
echo "Building the pinned Firefox e2e image..."
|
||||
docker build -t "$IMAGE" "$ROOT/tests/e2e/firefox"
|
||||
echo "Building the pinned Firefox e2e image (extension included)..."
|
||||
docker build --iidfile "$IIDFILE" -t "$IMAGE" \
|
||||
-f tests/e2e/firefox/Dockerfile .
|
||||
|
||||
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.
|
||||
# --network none: the suite stubs nothing, so this is what keeps the
|
||||
# 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
|
||||
# fixture interception, and honestly so — it proves no request
|
||||
# escaped, but it cannot report which ones were attempted.
|
||||
# --user: keep files the suite touches owned by the caller, not root.
|
||||
# HOME=/tmp: the mapped uid has no home directory in the image.
|
||||
# HOME=/tmp: the image's root home is not a reliable place for the
|
||||
# browser profile.
|
||||
#
|
||||
# No --privileged. Firefox's sandbox logs
|
||||
# "CanCreateUserNamespace() clone() failure: EPERM" on startup here;
|
||||
@@ -52,11 +69,8 @@ main() {
|
||||
docker run --rm \
|
||||
--shm-size=1g \
|
||||
--network none \
|
||||
--user "$(id -u):$(id -g)" \
|
||||
-e HOME=/tmp \
|
||||
-v "$ROOT:/work" \
|
||||
-w /work \
|
||||
"$IMAGE" \
|
||||
"$(cat "$IIDFILE")" \
|
||||
node tests/e2e/firefox/run.js dist/firefox
|
||||
}
|
||||
|
||||
|
||||
105
script/vendor-blocklist
Executable file
105
script/vendor-blocklist
Executable 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 "$@"
|
||||
@@ -24,32 +24,32 @@ const {
|
||||
TX_STAGE_VERIFY,
|
||||
TX_STAGE_BROADCAST,
|
||||
TX_STAGE_INFLIGHT,
|
||||
TX_STAGE_NONCE,
|
||||
} = require("../shared/approvalVerify");
|
||||
const { prepareApprovalTx } = require("../shared/approvalTx");
|
||||
const {
|
||||
isPhishingDomain,
|
||||
refreshPhishingListOnSchedule,
|
||||
initPhishingList,
|
||||
} = require("../shared/phishingDomains");
|
||||
const { isPhishingDomain } = require("../shared/phishingDomains");
|
||||
const {
|
||||
BALANCE_REFRESH_ALARM,
|
||||
PHISHING_REFRESH_ALARM,
|
||||
BALANCE_REFRESH_PERIOD_MINUTES,
|
||||
ensureRecurringAlarms,
|
||||
registerAlarmHandlers,
|
||||
} = require("../shared/alarms");
|
||||
|
||||
const storageApi =
|
||||
typeof browser !== "undefined"
|
||||
? browser.storage.local
|
||||
: chrome.storage.local;
|
||||
const runtime =
|
||||
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
|
||||
const windowsApi =
|
||||
typeof browser !== "undefined" ? browser.windows : chrome.windows;
|
||||
const tabsApi = typeof browser !== "undefined" ? browser.tabs : chrome.tabs;
|
||||
const actionApi =
|
||||
typeof browser !== "undefined" ? browser.browserAction : chrome.action;
|
||||
const {
|
||||
actionApi,
|
||||
runtimeApi,
|
||||
storageGet,
|
||||
tabsQuery,
|
||||
tabsSendMessage,
|
||||
windowsApi,
|
||||
windowsCreate,
|
||||
windowsGetLastFocused,
|
||||
windowsRemove,
|
||||
} = require("../shared/browserApi");
|
||||
|
||||
const runtime = runtimeApi();
|
||||
const windowsNs = windowsApi();
|
||||
const actionNs = actionApi();
|
||||
|
||||
// Connected sites (in-memory, non-persisted): { "origin:address": true }
|
||||
const connectedSites = {};
|
||||
@@ -57,8 +57,126 @@ const connectedSites = {};
|
||||
// Pending approval requests: { id: { origin, hostname, resolve } }
|
||||
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() {
|
||||
const result = await storageApi.get("autistmask");
|
||||
const result = await storageGet("autistmask");
|
||||
return (
|
||||
result.autistmask || {
|
||||
wallets: [],
|
||||
@@ -122,8 +240,8 @@ async function proxyRpc(method, params) {
|
||||
}
|
||||
|
||||
function resetPopupUrl() {
|
||||
if (actionApi && typeof actionApi.setPopup === "function") {
|
||||
actionApi.setPopup({ popup: "src/popup/index.html" });
|
||||
if (actionNs && typeof actionNs.setPopup === "function") {
|
||||
actionNs.setPopup({ popup: "src/popup/index.html" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,11 +266,41 @@ function settleApproval(id, result, options) {
|
||||
const holdsClaim = !!(options && options.holdsClaim);
|
||||
if (approval.attemptInFlight && !holdsClaim) return false;
|
||||
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);
|
||||
resetPopupUrl();
|
||||
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.
|
||||
//
|
||||
// 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.
|
||||
// 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) {
|
||||
approval.attemptInFlight = false;
|
||||
if (approval.windowClosed) {
|
||||
settleApproval(
|
||||
approval.id,
|
||||
abandonedResult(
|
||||
approval,
|
||||
APPROVAL_REJECTED_CODE,
|
||||
APPROVAL_REJECTED_MESSAGE,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Open approval in a separate popup window.
|
||||
// This is the primary mechanism for tx/sign approvals (triggered programmatically,
|
||||
// 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 popupWidth = 360;
|
||||
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 = {
|
||||
url: popupUrl,
|
||||
type: "popup",
|
||||
@@ -199,12 +374,42 @@ function openApprovalWindow(id) {
|
||||
currentWin.top + (currentWin.height - popupHeight) / 2,
|
||||
);
|
||||
}
|
||||
windowsApi.create(opts, (win) => {
|
||||
if (win) {
|
||||
pendingApprovals[id].windowId = win.id;
|
||||
|
||||
let win = null;
|
||||
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.
|
||||
@@ -212,14 +417,14 @@ function openApprovalWindow(id) {
|
||||
function requestApproval(origin, hostname) {
|
||||
return new Promise((resolve) => {
|
||||
const id = crypto.randomUUID();
|
||||
pendingApprovals[id] = { origin, hostname, resolve };
|
||||
pendingApprovals[id] = { id, origin, hostname, resolve };
|
||||
|
||||
if (actionApi && typeof actionApi.openPopup === "function") {
|
||||
actionApi.setPopup({
|
||||
if (actionNs && typeof actionNs.openPopup === "function") {
|
||||
actionNs.setPopup({
|
||||
popup: "src/popup/index.html?approval=" + id,
|
||||
});
|
||||
try {
|
||||
const result = actionApi.openPopup();
|
||||
const result = actionNs.openPopup();
|
||||
if (result && typeof result.catch === "function") {
|
||||
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
|
||||
// between approval and signing must refuse, not sign from an account this
|
||||
// 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) => {
|
||||
const id = crypto.randomUUID();
|
||||
pendingApprovals[id] = {
|
||||
id,
|
||||
origin,
|
||||
hostname,
|
||||
approvedTx,
|
||||
@@ -254,6 +462,7 @@ function requestTxApproval(origin, hostname, approvedTx, approvedFrom) {
|
||||
resolve,
|
||||
type: "tx",
|
||||
};
|
||||
if (slot) slot.approvalId = id;
|
||||
|
||||
openApprovalWindow(id);
|
||||
});
|
||||
@@ -267,6 +476,7 @@ function requestSignApproval(origin, hostname, signParams, approvedFrom) {
|
||||
return new Promise((resolve) => {
|
||||
const id = crypto.randomUUID();
|
||||
pendingApprovals[id] = {
|
||||
id,
|
||||
origin,
|
||||
hostname,
|
||||
signParams,
|
||||
@@ -279,13 +489,53 @@ function requestSignApproval(origin, hostname, signParams, approvedFrom) {
|
||||
});
|
||||
}
|
||||
|
||||
// Detect when an approval popup (browser-action) closes without a response.
|
||||
// TX and sign approvals now use windows.create() and are handled by the
|
||||
// windowsApi.onRemoved listener below, but we still handle site-connection
|
||||
// approval disconnects here.
|
||||
// Anything only the extension's own pages may say. A content script speaks
|
||||
// with the page's URL, so this is what separates the popup from the site the
|
||||
// popup is being asked about.
|
||||
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) => {
|
||||
if (port.name.startsWith("approval:")) {
|
||||
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(() => {
|
||||
const approval = pendingApprovals[id];
|
||||
if (approval) {
|
||||
@@ -295,7 +545,6 @@ runtime.onConnect.addListener((port) => {
|
||||
}
|
||||
settleApproval(id, { approved: false, remember: false });
|
||||
}
|
||||
resetPopupUrl();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -585,10 +834,30 @@ async function handleRpc(method, params, origin) {
|
||||
}
|
||||
|
||||
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 activeAddress = await getActiveAddress();
|
||||
if (!activeAddress)
|
||||
return { error: { message: "No accounts available" } };
|
||||
if (!activeAddress) return { error: { message: "No accounts available" } };
|
||||
|
||||
const hostname = extractHostname(origin);
|
||||
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
|
||||
// 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
|
||||
@@ -644,43 +930,40 @@ async function handleRpc(method, params, origin) {
|
||||
hostname,
|
||||
approvedTx,
|
||||
activeAddress,
|
||||
slot,
|
||||
);
|
||||
if (decision.error) return { error: decision.error };
|
||||
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.
|
||||
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) {
|
||||
tabsApi.sendMessage(
|
||||
tab.id,
|
||||
{
|
||||
// A tab with no content script has no receiver, and that is the
|
||||
// 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",
|
||||
eventName: "chainChanged",
|
||||
data: chainId,
|
||||
},
|
||||
() => {
|
||||
if (runtime.lastError) {
|
||||
// expected for tabs without our content script
|
||||
}).catch(() => {});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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
|
||||
// page nor closing the window it is reporting into is survivable.
|
||||
for (const [id, approval] of Object.entries(pendingApprovals)) {
|
||||
const rejection =
|
||||
approval.type === "tx" || approval.type === "sign"
|
||||
? {
|
||||
error: {
|
||||
code: 4001,
|
||||
message: "User rejected the request.",
|
||||
},
|
||||
}
|
||||
: { approved: false, remember: false };
|
||||
const rejection = abandonedResult(
|
||||
approval,
|
||||
APPROVAL_REJECTED_CODE,
|
||||
APPROVAL_REJECTED_MESSAGE,
|
||||
);
|
||||
if (!settleApproval(id, rejection)) continue;
|
||||
if (approval.windowId) {
|
||||
windowsApi.remove(approval.windowId, () => {
|
||||
if (runtime.lastError) {
|
||||
// window already closed
|
||||
}
|
||||
});
|
||||
// Rejects when the window has already gone, which is a race the
|
||||
// user wins routinely by closing it themselves.
|
||||
windowsRemove(approval.windowId).catch(() => {});
|
||||
}
|
||||
}
|
||||
resetPopupUrl();
|
||||
const s = await getState();
|
||||
const activeAddress = await getActiveAddress();
|
||||
const allowed = activeAddress ? s.allowedSites[activeAddress] || [] : [];
|
||||
tabsApi.query({}, (tabs) => {
|
||||
let tabs;
|
||||
try {
|
||||
tabs = await tabsQuery({});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const tab of tabs) {
|
||||
const origin = tab.url ? new URL(tab.url).origin : "";
|
||||
const hostname = extractHostname(origin);
|
||||
@@ -724,22 +1006,14 @@ async function broadcastAccountsChanged() {
|
||||
activeAddress &&
|
||||
(allowed.includes(hostname) ||
|
||||
connectedSites[origin + ":" + activeAddress]);
|
||||
tabsApi.sendMessage(
|
||||
tab.id,
|
||||
{
|
||||
// Same as chainChanged above: a tab without our content script
|
||||
// rejects, and that is expected rather than a fault.
|
||||
tabsSendMessage(tab.id, {
|
||||
type: "AUTISTMASK_EVENT",
|
||||
eventName: "accountsChanged",
|
||||
data: hasPermission ? [activeAddress] : [],
|
||||
},
|
||||
() => {
|
||||
// Ignore errors for tabs without content script
|
||||
if (runtime.lastError) {
|
||||
// expected for tabs without our content script
|
||||
}).catch(() => {});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Background balance refresh: every 60 seconds when the popup isn't open.
|
||||
@@ -773,26 +1047,20 @@ async function backgroundRefresh() {
|
||||
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,
|
||||
// so a setInterval would only ever survive until the first idle period and
|
||||
// module-level state does not outlive it. Alarms are held by the browser and
|
||||
// wake the worker to deliver them.
|
||||
registerAlarmHandlers({
|
||||
[BALANCE_REFRESH_ALARM]: backgroundRefresh,
|
||||
// The scheduled refresh, which restores persisted state on a freshly
|
||||
// revived worker and then fetches unconditionally. The freshness guards
|
||||
// belong to the startup path; applying them here would make the tick skip
|
||||
// itself.
|
||||
[PHISHING_REFRESH_ALARM]: refreshPhishingListOnSchedule,
|
||||
});
|
||||
|
||||
// Everything the background context needs re-established on start. This runs
|
||||
// on a fresh install, on browser startup, and on every revival of a
|
||||
// terminated worker, so it must be idempotent: ensureRecurringAlarms() only
|
||||
// creates alarms that are missing or carrying a stale period, and
|
||||
// initPhishingList() fetches only when the persisted timestamps say the list
|
||||
// is stale.
|
||||
// creates alarms that are missing or carrying a stale period, and only clears
|
||||
// retired ones that are still registered.
|
||||
//
|
||||
// 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.
|
||||
@@ -803,10 +1071,7 @@ let backgroundJobsRun = null;
|
||||
|
||||
function startBackgroundJobs() {
|
||||
if (backgroundJobsRun) return backgroundJobsRun;
|
||||
backgroundJobsRun = Promise.all([
|
||||
ensureRecurringAlarms(),
|
||||
initPhishingList(),
|
||||
])
|
||||
backgroundJobsRun = ensureRecurringAlarms()
|
||||
.catch((err) => {
|
||||
// An alarm that failed to schedule means a recurring job silently
|
||||
// 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
|
||||
// window is an ordinary event with an attempt already in flight behind it.
|
||||
// settleApproval() refuses those, which leaves the attempt to report its real
|
||||
// outcome to the page.
|
||||
if (windowsApi && windowsApi.onRemoved) {
|
||||
windowsApi.onRemoved.addListener((windowId) => {
|
||||
// outcome to the page — and the window is recorded as gone, so that an attempt
|
||||
// which then fails retryably settles instead of waiting in a window that no
|
||||
// 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)) {
|
||||
if (approval.windowId !== windowId) continue;
|
||||
const rejection =
|
||||
approval.type === "tx" || approval.type === "sign"
|
||||
? {
|
||||
error: {
|
||||
code: 4001,
|
||||
message: "User rejected the request.",
|
||||
},
|
||||
}
|
||||
: { approved: false, remember: false };
|
||||
settleApproval(id, rejection);
|
||||
const isSite = approval.type !== "tx" && approval.type !== "sign";
|
||||
if (isSite && approval.portConnected) continue;
|
||||
const rejection = abandonedResult(
|
||||
approval,
|
||||
APPROVAL_REJECTED_CODE,
|
||||
APPROVAL_REJECTED_MESSAGE,
|
||||
);
|
||||
if (!settleApproval(id, rejection)) approval.windowClosed = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -865,26 +1139,41 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
// keep fallback
|
||||
}
|
||||
}
|
||||
handleRpc(msg.method, msg.params, trustedOrigin).then((response) => {
|
||||
handleRpc(msg.method, msg.params, trustedOrigin)
|
||||
.then((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;
|
||||
}
|
||||
|
||||
// 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 = [
|
||||
"AUTISTMASK_GET_APPROVAL",
|
||||
"AUTISTMASK_APPROVAL_RESPONSE",
|
||||
"AUTISTMASK_TX_RESPONSE",
|
||||
"AUTISTMASK_SIGN_RESPONSE",
|
||||
];
|
||||
if (POPUP_ONLY_TYPES.includes(msg.type)) {
|
||||
const extUrl = runtime.getURL("");
|
||||
if (!sender.url || !sender.url.startsWith(extUrl)) {
|
||||
if (POPUP_ONLY_TYPES.includes(msg.type) && !isExtensionSender(sender)) {
|
||||
sendResponse({ error: "Unauthorized sender" });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.type === "AUTISTMASK_GET_APPROVAL") {
|
||||
const approval = pendingApprovals[msg.id];
|
||||
@@ -915,15 +1204,6 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
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") {
|
||||
const approval = pendingApprovals[msg.id];
|
||||
if (!approval) return false;
|
||||
@@ -959,7 +1239,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
sendResponse({
|
||||
error: outcome.error,
|
||||
retryable: outcome.retryable,
|
||||
stage: TX_STAGE_SIGN,
|
||||
stage: outcome.stage,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
@@ -974,9 +1254,20 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
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 () => {
|
||||
// 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 {
|
||||
await loadState();
|
||||
chainId = currentNetwork().chainId;
|
||||
const activeAddress = await getActiveAddress();
|
||||
// An address switch between approval and signing refuses. The
|
||||
// approval named one account; signing from whichever account
|
||||
@@ -999,7 +1290,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
msg.rawSignedTx,
|
||||
approval.approvedTx,
|
||||
approval.approvedFrom,
|
||||
currentNetwork().chainId,
|
||||
chainId,
|
||||
);
|
||||
} catch (e) {
|
||||
// A signed transaction that is not the approved one is not
|
||||
@@ -1019,14 +1310,40 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
sendResponse({
|
||||
error: outcome.error,
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
const provider = getProvider(state.rpcUrl);
|
||||
lastResortStage = TX_STAGE_BROADCAST;
|
||||
const tx = await provider.broadcastTransaction(msg.rawSignedTx);
|
||||
if (nonce !== null) spent.add(nonce);
|
||||
settleApproval(
|
||||
msg.id,
|
||||
{ txHash: tx.hash },
|
||||
@@ -1039,6 +1356,11 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
// tell a transaction that never left from one already in the
|
||||
// mempool. The page has been given its outcome for this
|
||||
// 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);
|
||||
settleApproval(
|
||||
msg.id,
|
||||
@@ -1048,10 +1370,31 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
sendResponse({
|
||||
error: outcome.error,
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1135,7 +1478,25 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
// AutistMask content script — bridges between inpage (window.ethereum)
|
||||
// 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 the manifest, so no injection is needed here. In Firefox (MV2), the
|
||||
// "world" key is not supported, so we inject via a <script> tag.
|
||||
if (typeof browser !== "undefined") {
|
||||
if (hasBrowserNamespace()) {
|
||||
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 () {
|
||||
this.remove();
|
||||
};
|
||||
@@ -14,23 +22,27 @@ if (typeof browser !== "undefined") {
|
||||
}
|
||||
|
||||
// Send the persisted EIP-6963 provider UUID to the inpage script.
|
||||
// Generated once at install time and stored in chrome.storage.local.
|
||||
(function sendProviderUuid() {
|
||||
const storage =
|
||||
typeof browser !== "undefined"
|
||||
? browser.storage.local
|
||||
: chrome.storage.local;
|
||||
storage.get("eip6963Uuid", (items) => {
|
||||
let uuid = items?.eip6963Uuid;
|
||||
// Generated once at install time and stored in extension storage.
|
||||
(async function sendProviderUuid() {
|
||||
let uuid = null;
|
||||
try {
|
||||
const items = await storageGet("eip6963Uuid");
|
||||
uuid = items?.eip6963Uuid;
|
||||
if (!uuid) {
|
||||
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(
|
||||
{ type: "AUTISTMASK_PROVIDER_UUID", uuid },
|
||||
location.origin,
|
||||
);
|
||||
});
|
||||
})();
|
||||
|
||||
// Relay requests from the page to the background script
|
||||
@@ -39,27 +51,31 @@ window.addEventListener("message", (event) => {
|
||||
if (event.data?.type !== "AUTISTMASK_REQUEST") return;
|
||||
const { id, method, params } = event.data;
|
||||
|
||||
const runtime =
|
||||
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
|
||||
|
||||
runtime.sendMessage(
|
||||
{ type: "AUTISTMASK_RPC", id, method, params, origin: location.origin },
|
||||
(response) => {
|
||||
sendMessage({
|
||||
type: "AUTISTMASK_RPC",
|
||||
id,
|
||||
method,
|
||||
params,
|
||||
origin: location.origin,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response) {
|
||||
window.postMessage(
|
||||
{ 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)
|
||||
const runtime =
|
||||
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
|
||||
|
||||
runtime.onMessage.addListener((msg) => {
|
||||
runtimeApi().onMessage.addListener((msg) => {
|
||||
if (msg.type === "AUTISTMASK_EVENT") {
|
||||
window.postMessage(
|
||||
{
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
for (const cb of cbs) {
|
||||
try {
|
||||
cb(data);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// ignore listener errors
|
||||
}
|
||||
}
|
||||
@@ -179,7 +179,8 @@
|
||||
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: {
|
||||
isUnlocked() {
|
||||
return Promise.resolve(provider.selectedAddress !== null);
|
||||
|
||||
@@ -12,7 +12,6 @@ const {
|
||||
setBackRenderer,
|
||||
pushCurrentView,
|
||||
goBack,
|
||||
clearViewStack,
|
||||
} = require("./views/helpers");
|
||||
const { applyTheme } = require("./theme");
|
||||
// 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 approvalId = params.get("approval");
|
||||
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);
|
||||
showView("approve-site");
|
||||
return;
|
||||
|
||||
@@ -194,7 +194,7 @@ async function importPrivateKey(ctx) {
|
||||
let addr;
|
||||
try {
|
||||
addr = addressFromPrivateKey(key);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
showFlash("Invalid private key.");
|
||||
return;
|
||||
}
|
||||
@@ -246,7 +246,7 @@ async function importXprvKey(ctx) {
|
||||
let result;
|
||||
try {
|
||||
result = hdWalletFromXprv(xprv);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
showFlash(
|
||||
"That extended private key is not valid. Please check it and try again.",
|
||||
);
|
||||
|
||||
@@ -12,8 +12,8 @@ const {
|
||||
goBack,
|
||||
pushCurrentView,
|
||||
} = require("./helpers");
|
||||
const { state, currentAddress, saveState } = require("../../shared/state");
|
||||
const { formatUsd, getAddressValueUsd } = require("../../shared/prices");
|
||||
const { state, saveState } = require("../../shared/state");
|
||||
const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
|
||||
const {
|
||||
fetchRecentTransactions,
|
||||
filterTransactions,
|
||||
@@ -44,7 +44,6 @@ function show() {
|
||||
state.selectedToken = null;
|
||||
const wallet = state.wallets[state.selectedWallet];
|
||||
const addr = wallet.addresses[state.selectedAddress];
|
||||
const wi = state.selectedWallet;
|
||||
const ai = state.selectedAddress;
|
||||
$("address-title").textContent =
|
||||
wallet.name + " \u2014 Address " + (ai + 1);
|
||||
@@ -64,7 +63,7 @@ function show() {
|
||||
});
|
||||
$("address-line").dataset.full = addr.address;
|
||||
attachCopyHandlers($("address-line"));
|
||||
const usdTotal = formatUsd(getAddressValueUsd(addr));
|
||||
const usdTotal = formatAddressTotal(getAddressValue(addr));
|
||||
$("address-usd-total").innerHTML = usdTotal || " ";
|
||||
const ensEl = $("address-ens");
|
||||
// ENS is now shown inside renderAddressHtml, hide the separate element
|
||||
@@ -246,7 +245,6 @@ function renderTransactions(txs) {
|
||||
row.addEventListener("click", () => {
|
||||
const idx = parseInt(row.dataset.tx, 10);
|
||||
const tx = loadedTxs[idx];
|
||||
const counterparty = tx.direction === "sent" ? tx.to : tx.from;
|
||||
tx.fromEns = ensNameMap.get(tx.from) || null;
|
||||
tx.toEns = ensNameMap.get(tx.to) || null;
|
||||
ctx.showTransactionDetail(tx);
|
||||
|
||||
@@ -16,13 +16,9 @@ const {
|
||||
goBack,
|
||||
pushCurrentView,
|
||||
} = require("./helpers");
|
||||
const { state, currentAddress, saveState } = require("../../shared/state");
|
||||
const { state, saveState } = require("../../shared/state");
|
||||
const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
|
||||
const {
|
||||
formatUsd,
|
||||
getPrice,
|
||||
getAddressValueUsd,
|
||||
} = require("../../shared/prices");
|
||||
const { formatUsd, getPrice } = require("../../shared/prices");
|
||||
const {
|
||||
fetchRecentTransactions,
|
||||
filterTransactions,
|
||||
|
||||
@@ -9,7 +9,7 @@ const {
|
||||
attachCopyHandlers,
|
||||
onViewLeave,
|
||||
} = require("./helpers");
|
||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||
const { state, saveState } = require("../../shared/state");
|
||||
const { networkByChainId } = require("../../shared/networks");
|
||||
const {
|
||||
formatEther,
|
||||
@@ -27,8 +27,7 @@ const { walletDefect } = require("../../shared/walletDefects");
|
||||
const { describeSigningFailure } = require("../../shared/approvalVerify");
|
||||
const txStatus = require("./txStatus");
|
||||
const uniswap = require("../../shared/uniswap");
|
||||
const runtime =
|
||||
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
|
||||
const { notify, runtimeApi, sendMessage } = require("../../shared/browserApi");
|
||||
|
||||
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;
|
||||
runtime.connect({ name: "approval:" + id });
|
||||
runtime.sendMessage({ type: "AUTISTMASK_GET_APPROVAL", id }, (details) => {
|
||||
approvalPort = runtimeApi().connect({ name: "approval:" + id });
|
||||
|
||||
let details = null;
|
||||
try {
|
||||
details = await sendMessage({ type: "AUTISTMASK_GET_APPROVAL", id });
|
||||
} catch {
|
||||
details = null;
|
||||
}
|
||||
|
||||
if (!details) {
|
||||
window.close();
|
||||
return;
|
||||
@@ -461,15 +470,20 @@ function show(id) {
|
||||
details.isPhishingDomain,
|
||||
);
|
||||
$("approve-hostname").textContent = details.hostname;
|
||||
$("approve-address").innerHTML = approvalAddressHtml(
|
||||
state.activeAddress,
|
||||
);
|
||||
$("approve-address").innerHTML = approvalAddressHtml(state.activeAddress);
|
||||
attachCopyHandlers("view-approve-site");
|
||||
$("approve-remember").checked = state.rememberSiteChoice;
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
// 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
|
||||
@@ -537,7 +551,29 @@ function clearSignPassword() {
|
||||
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-sign", clearSignPassword);
|
||||
|
||||
@@ -547,25 +583,11 @@ function init(ctx) {
|
||||
});
|
||||
|
||||
$("btn-approve").addEventListener("click", () => {
|
||||
const remember = $("approve-remember").checked;
|
||||
runtime.sendMessage({
|
||||
type: "AUTISTMASK_APPROVAL_RESPONSE",
|
||||
id: approvalId,
|
||||
approved: true,
|
||||
remember,
|
||||
});
|
||||
window.close();
|
||||
decideSite(true);
|
||||
});
|
||||
|
||||
$("btn-reject").addEventListener("click", () => {
|
||||
const remember = $("approve-remember").checked;
|
||||
runtime.sendMessage({
|
||||
type: "AUTISTMASK_APPROVAL_RESPONSE",
|
||||
id: approvalId,
|
||||
approved: false,
|
||||
remember,
|
||||
});
|
||||
window.close();
|
||||
decideSite(false);
|
||||
});
|
||||
|
||||
$("btn-approve-tx").addEventListener("click", async () => {
|
||||
@@ -648,7 +670,16 @@ function init(ctx) {
|
||||
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) {
|
||||
txStatus.showWait(pendingTxDetails, response.txHash);
|
||||
return;
|
||||
@@ -667,10 +698,9 @@ function init(ctx) {
|
||||
txStatus.showError(pendingTxDetails, null, outcome.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$("btn-reject-tx").addEventListener("click", () => {
|
||||
runtime.sendMessage({
|
||||
notify({
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id: approvalId,
|
||||
approved: false,
|
||||
@@ -764,7 +794,13 @@ function init(ctx) {
|
||||
decryptedSecret = null;
|
||||
}
|
||||
|
||||
runtime.sendMessage(payload, (response) => {
|
||||
let response = null;
|
||||
try {
|
||||
response = await sendMessage(payload);
|
||||
} catch {
|
||||
response = null;
|
||||
}
|
||||
|
||||
if (response && response.signature) {
|
||||
window.close();
|
||||
return;
|
||||
@@ -780,10 +816,9 @@ function init(ctx) {
|
||||
showError("approve-sign-error", outcome.message);
|
||||
if (outcome.retryable) setSignButtonBusy(false);
|
||||
});
|
||||
});
|
||||
|
||||
$("btn-reject-sign").addEventListener("click", () => {
|
||||
runtime.sendMessage({
|
||||
notify({
|
||||
type: "AUTISTMASK_SIGN_RESPONSE",
|
||||
id: approvalId,
|
||||
approved: false,
|
||||
|
||||
@@ -2,20 +2,12 @@
|
||||
// Shows transaction details, warnings, errors. On Sign & Send,
|
||||
// reads inline password, decrypts secret, signs and broadcasts.
|
||||
|
||||
const {
|
||||
parseEther,
|
||||
parseUnits,
|
||||
formatEther,
|
||||
formatUnits,
|
||||
Contract,
|
||||
} = require("ethers");
|
||||
const { parseEther, parseUnits, formatEther, Contract } = require("ethers");
|
||||
const {
|
||||
$,
|
||||
showError,
|
||||
hideError,
|
||||
showView,
|
||||
showFlash,
|
||||
flashCopyFeedback,
|
||||
addressTitle,
|
||||
escapeHtml,
|
||||
renderAddressHtml,
|
||||
@@ -23,7 +15,7 @@ const {
|
||||
goBack,
|
||||
onViewLeave,
|
||||
} = require("./helpers");
|
||||
const { state, currentNetwork } = require("../../shared/state");
|
||||
const { state } = require("../../shared/state");
|
||||
const { getSignerForAddress } = require("../../shared/wallet");
|
||||
const { decryptWithPassword } = require("../../shared/vault");
|
||||
const { formatUsd, getPrice } = require("../../shared/prices");
|
||||
@@ -399,7 +391,7 @@ function clearPassword() {
|
||||
hideError("confirm-tx-password-error");
|
||||
}
|
||||
|
||||
function init(ctx) {
|
||||
function init(_ctx) {
|
||||
onViewLeave("confirm-tx", clearPassword);
|
||||
|
||||
$("btn-confirm-send").addEventListener("click", async () => {
|
||||
@@ -421,7 +413,7 @@ function init(ctx) {
|
||||
wallet.encryptedSecret,
|
||||
password,
|
||||
);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
showError(
|
||||
"confirm-tx-password-error",
|
||||
"That password is incorrect. Please try again.",
|
||||
|
||||
@@ -17,7 +17,7 @@ const {
|
||||
addressHoldsFunds,
|
||||
balanceLinesForAddress,
|
||||
} = require("./helpers");
|
||||
const { formatUsd, getAddressValueUsd } = require("../../shared/prices");
|
||||
const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
|
||||
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
|
||||
const { state, saveState } = require("../../shared/state");
|
||||
const {
|
||||
@@ -84,16 +84,16 @@ function recoveryPathText(wallet) {
|
||||
// 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.
|
||||
// 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
|
||||
// testnet and before the first price fetch, where the line is left off rather
|
||||
// than printed as $0.00).
|
||||
// AddressDetail, followed by the USD total when there is one to give — no
|
||||
// total line at all on testnet or before the first price fetch, and no figure
|
||||
// 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) {
|
||||
if (!addressHoldsFunds(addr)) return " ";
|
||||
const usd = getAddressValueUsd(addr);
|
||||
const total =
|
||||
usd === null
|
||||
? ""
|
||||
: `<div class="text-xs text-muted mt-1">Total: ${formatUsd(usd)}</div>`;
|
||||
const line = formatAddressTotal(getAddressValue(addr));
|
||||
const total = line
|
||||
? `<div class="text-xs text-muted mt-1">${line}</div>`
|
||||
: "";
|
||||
return (
|
||||
`<p class="mb-1">This address holds a balance. Removing it does not ` +
|
||||
`move or spend anything; the balance stays at the address.</p>` +
|
||||
|
||||
@@ -73,7 +73,7 @@ function init(_ctx) {
|
||||
// Verify password against the wallet's encrypted data
|
||||
try {
|
||||
await decryptWithPassword(wallet.encryptedSecret, pw);
|
||||
} catch (_e) {
|
||||
} catch {
|
||||
$("delete-wallet-flash").textContent =
|
||||
"That password is incorrect. Please try again.";
|
||||
$("delete-wallet-flash").style.visibility = "visible";
|
||||
|
||||
@@ -112,7 +112,7 @@ function show(walletIdx, addrIdx) {
|
||||
async function reveal() {
|
||||
const password = $("export-privkey-password").value;
|
||||
if (!password) {
|
||||
fail("Password is required.");
|
||||
fail("Please enter your password.");
|
||||
return;
|
||||
}
|
||||
if (walletIndex === null) {
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
// Shared DOM helpers used by all views.
|
||||
|
||||
const { isDebug } = require("../../shared/log");
|
||||
const {
|
||||
formatUsd,
|
||||
getPrice,
|
||||
getAddressValueUsd,
|
||||
} = require("../../shared/prices");
|
||||
const { formatUsd, getPrice } = require("../../shared/prices");
|
||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||
const { markViewRendered } = require("../viewRouter");
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ const {
|
||||
$,
|
||||
showView,
|
||||
showFlash,
|
||||
flashCopyFeedback,
|
||||
balanceLinesForAddress,
|
||||
isoDate,
|
||||
timeAgo,
|
||||
@@ -15,6 +14,7 @@ const {
|
||||
pushCurrentView,
|
||||
} = require("./helpers");
|
||||
const { state, saveState, currentAddress } = require("../../shared/state");
|
||||
const { notify } = require("../../shared/browserApi");
|
||||
const {
|
||||
updateSendBalance,
|
||||
renderSendTokenSelect,
|
||||
@@ -28,8 +28,9 @@ const {
|
||||
} = require("../../shared/walletDefects");
|
||||
const {
|
||||
formatUsd,
|
||||
formatAddressTotal,
|
||||
getPrice,
|
||||
getAddressValueUsd,
|
||||
getAddressValue,
|
||||
} = require("../../shared/prices");
|
||||
const {
|
||||
fetchRecentTransactions,
|
||||
@@ -71,9 +72,7 @@ function renderTotalValue() {
|
||||
el.textContent = ethStr + ethUsd;
|
||||
|
||||
if (subEl) {
|
||||
const totalUsd = getAddressValueUsd(addr);
|
||||
subEl.innerHTML =
|
||||
totalUsd !== null ? "Total: " + formatUsd(totalUsd) : " ";
|
||||
subEl.innerHTML = formatAddressTotal(getAddressValue(addr)) || " ";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,8 +256,8 @@ function walletListHtml() {
|
||||
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 += `</div>`;
|
||||
const addrUsd = formatUsd(getAddressValueUsd(addr));
|
||||
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrUsd || " "}</div>`;
|
||||
const addrTotal = formatAddressTotal(getAddressValue(addr));
|
||||
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrTotal || " "}</div>`;
|
||||
html += balanceLinesForAddress(
|
||||
addr,
|
||||
state.trackedTokens,
|
||||
@@ -293,11 +292,7 @@ function render(ctx) {
|
||||
state.activeAddress = addr;
|
||||
await saveState();
|
||||
render(ctx);
|
||||
const runtime =
|
||||
typeof browser !== "undefined"
|
||||
? browser.runtime
|
||||
: chrome.runtime;
|
||||
runtime.sendMessage({ type: "AUTISTMASK_ACTIVE_CHANGED" });
|
||||
notify({ type: "AUTISTMASK_ACTIVE_CHANGED" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,7 +57,7 @@ function show() {
|
||||
attachCopyHandlers("view-receive");
|
||||
}
|
||||
|
||||
function init(ctx) {
|
||||
function init(_ctx) {
|
||||
$("btn-receive-copy").addEventListener("click", () => {
|
||||
const addr = $("receive-address-block").dataset.full;
|
||||
if (addr) {
|
||||
|
||||
@@ -4,7 +4,6 @@ const {
|
||||
$,
|
||||
showFlash,
|
||||
addressTitle,
|
||||
escapeHtml,
|
||||
renderAddressHtml,
|
||||
attachCopyHandlers,
|
||||
goBack,
|
||||
@@ -210,7 +209,7 @@ function init(_ctx) {
|
||||
}
|
||||
resolvedTo = resolved;
|
||||
ensName = to;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
showFlash("Failed to resolve ENS name.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ const {
|
||||
parseDustThresholdGwei,
|
||||
} = require("../dustThreshold");
|
||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||
const { NETWORKS, SUPPORTED_CHAIN_IDS } = require("../../shared/networks");
|
||||
const { onChainSwitch } = require("../../shared/chainSwitch");
|
||||
const { log, debugFetch, setRuntimeDebug } = require("../../shared/log");
|
||||
const deleteWallet = require("./deleteWallet");
|
||||
@@ -29,8 +28,7 @@ const {
|
||||
GITEA_COMMIT_URL,
|
||||
} = require("../../shared/buildInfo");
|
||||
|
||||
const runtime =
|
||||
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
|
||||
const { notify } = require("../../shared/browserApi");
|
||||
|
||||
let versionClickCount = 0;
|
||||
let versionClickTimer = null;
|
||||
@@ -61,7 +59,7 @@ function renderSiteList(containerId, siteMap, stateKey) {
|
||||
}
|
||||
}
|
||||
await saveState();
|
||||
runtime.sendMessage({ type: "AUTISTMASK_REMOVE_SITE" });
|
||||
notify({ type: "AUTISTMASK_REMOVE_SITE" });
|
||||
renderSiteList(containerId, state[key], key);
|
||||
});
|
||||
});
|
||||
@@ -169,10 +167,7 @@ function renderWalletListSettings() {
|
||||
function show() {
|
||||
$("settings-rpc").value = state.rpcUrl;
|
||||
$("settings-blockscout").value = state.blockscoutUrl;
|
||||
const networkSelect = $("settings-network");
|
||||
if (networkSelect) {
|
||||
networkSelect.value = state.networkId;
|
||||
}
|
||||
$("settings-network").value = state.networkId;
|
||||
renderTrackedTokens();
|
||||
renderSiteLists();
|
||||
renderWalletListSettings();
|
||||
@@ -284,7 +279,6 @@ function init(ctx) {
|
||||
});
|
||||
|
||||
const networkSelect = $("settings-network");
|
||||
if (networkSelect) {
|
||||
networkSelect.addEventListener("change", async () => {
|
||||
const newId = networkSelect.value;
|
||||
const net = await onChainSwitch(newId);
|
||||
@@ -292,7 +286,6 @@ function init(ctx) {
|
||||
$("settings-blockscout").value = state.blockscoutUrl;
|
||||
showFlash("Switched to " + net.name + ".");
|
||||
});
|
||||
}
|
||||
|
||||
$("settings-show-zero-balances").checked = state.showZeroBalanceTokens;
|
||||
$("settings-show-zero-balances").addEventListener("change", async () => {
|
||||
|
||||
@@ -23,8 +23,6 @@ const makeBlockie = require("ethereum-blockies-base64");
|
||||
const { log, debugFetch } = require("../../shared/log");
|
||||
const { decodeCalldata } = require("./approval");
|
||||
|
||||
let ctx;
|
||||
|
||||
/**
|
||||
* Determine a human-readable transaction type string from tx fields.
|
||||
*/
|
||||
@@ -166,7 +164,7 @@ function render() {
|
||||
if (el) el.classList.add("hidden");
|
||||
}
|
||||
|
||||
loadFullTxDetails(tx.hash, tx.to, tx.isContractCall);
|
||||
loadFullTxDetails(tx.hash, tx.to);
|
||||
|
||||
const isoStr = isoDate(tx.timestamp);
|
||||
$("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 actionEl = $("tx-detail-calldata-action");
|
||||
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) {
|
||||
ctx = _ctx;
|
||||
$("btn-tx-back").addEventListener("click", () => {
|
||||
goBack();
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ const {
|
||||
clearViewStack,
|
||||
} = require("./helpers");
|
||||
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 { log } = require("../../shared/log");
|
||||
|
||||
@@ -229,10 +229,6 @@ function tokenLabel(address) {
|
||||
return t ? t.symbol : null;
|
||||
}
|
||||
|
||||
function etherscanTokenLink(address) {
|
||||
return `${currentNetwork().explorerUrl}/token/${address}`;
|
||||
}
|
||||
|
||||
function decodedDetailsHtml(decoded) {
|
||||
if (!decoded || !decoded.details) return "";
|
||||
let html = `<div class="border border-border border-dashed p-2 mb-3">`;
|
||||
|
||||
@@ -17,23 +17,31 @@
|
||||
// run finished and the alarm fires one run-duration earlier than that. Every
|
||||
// guard must therefore either be strictly shorter than the period it gates or
|
||||
// be bypassed on the scheduled tick — see backgroundRefresh() in
|
||||
// src/background/index.js and updatePhishingList() in shared/phishingDomains.js.
|
||||
// src/background/index.js.
|
||||
|
||||
const { alarmsApi } = require("./browserApi");
|
||||
|
||||
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 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
|
||||
// the module.
|
||||
function alarmsApi() {
|
||||
if (typeof browser !== "undefined" && browser.alarms) return browser.alarms;
|
||||
if (typeof chrome !== "undefined" && chrome.alarms) return chrome.alarms;
|
||||
return null;
|
||||
}
|
||||
// this module. It returns null where the API is absent, which is why every
|
||||
// entry point below degrades instead of throwing.
|
||||
|
||||
/**
|
||||
* 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
|
||||
* worker start, on onInstalled and on onStartup.
|
||||
* Clear every alarm this extension no longer handles.
|
||||
*
|
||||
* @returns {Promise<{balance: boolean, phishing: boolean}>} which alarms this
|
||||
* call had to create.
|
||||
* @returns {Promise<string[]>} the retired alarms this call actually cleared.
|
||||
*/
|
||||
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() {
|
||||
const balance = await ensureAlarm(
|
||||
BALANCE_REFRESH_ALARM,
|
||||
BALANCE_REFRESH_PERIOD_MINUTES,
|
||||
);
|
||||
const phishing = await ensureAlarm(
|
||||
PHISHING_REFRESH_ALARM,
|
||||
PHISHING_REFRESH_PERIOD_MINUTES,
|
||||
);
|
||||
return { balance, phishing };
|
||||
const cleared = await clearObsoleteAlarms();
|
||||
return { balance, cleared };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,10 +124,10 @@ function registerAlarmHandlers(handlers) {
|
||||
|
||||
module.exports = {
|
||||
BALANCE_REFRESH_ALARM,
|
||||
PHISHING_REFRESH_ALARM,
|
||||
OBSOLETE_ALARMS,
|
||||
MIN_ALARM_PERIOD_MINUTES,
|
||||
BALANCE_REFRESH_PERIOD_MINUTES,
|
||||
PHISHING_REFRESH_PERIOD_MINUTES,
|
||||
clearObsoleteAlarms,
|
||||
ensureAlarm,
|
||||
ensureRecurringAlarms,
|
||||
registerAlarmHandlers,
|
||||
|
||||
@@ -602,6 +602,12 @@ const TX_STAGE_BROADCAST = "broadcast";
|
||||
// may yet succeed, so the one thing the popup must not say is "start again
|
||||
// from the site".
|
||||
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) {
|
||||
if (typeof err === "string" && err !== "") return err;
|
||||
@@ -611,6 +617,59 @@ function errorText(err) {
|
||||
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
|
||||
// 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
|
||||
@@ -627,12 +686,31 @@ function errorText(err) {
|
||||
// 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
|
||||
// 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) {
|
||||
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 retryable =
|
||||
stage === TX_STAGE_SIGN ||
|
||||
(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
|
||||
@@ -642,14 +720,20 @@ function describeTxFailure(stage, err) {
|
||||
//
|
||||
// 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
|
||||
// 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) {
|
||||
let message = (response && response.error) || fallbackMessage;
|
||||
if (!/[.!?]$/.test(message)) message += ".";
|
||||
const retryable = !!(response && response.retryable);
|
||||
const stage = response && response.stage;
|
||||
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 +=
|
||||
" The transaction may still have reached the network." +
|
||||
" Check the account before sending it again.";
|
||||
@@ -675,9 +759,11 @@ module.exports = {
|
||||
assertWithinCeilings,
|
||||
sameAddress,
|
||||
failureIsRetryable,
|
||||
isNonceCollision,
|
||||
describeTxFailure,
|
||||
describeSigningFailure,
|
||||
ApprovalMismatchError,
|
||||
NONCE_COLLISION_MESSAGE,
|
||||
ALLOWED_TX_TYPES,
|
||||
SERIALIZED_FIELDS,
|
||||
FORBIDDEN_FIELDS,
|
||||
@@ -686,6 +772,7 @@ module.exports = {
|
||||
TX_STAGE_VERIFY,
|
||||
TX_STAGE_BROADCAST,
|
||||
TX_STAGE_INFLIGHT,
|
||||
TX_STAGE_NONCE,
|
||||
MAX_GAS_LIMIT,
|
||||
MAX_FEE_PER_GAS,
|
||||
};
|
||||
|
||||
262
src/shared/browserApi.js
Normal file
262
src/shared/browserApi.js
Normal 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
41
src/shared/domainHash.js
Normal 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,
|
||||
};
|
||||
@@ -4,8 +4,7 @@
|
||||
//
|
||||
// POPUP ONLY. localStorage does not exist in the Chrome MV3 service worker,
|
||||
// so this module must not be pulled into src/background/. Anything the
|
||||
// background context needs to cache goes in extension storage instead (see
|
||||
// shared/phishingDomains.js).
|
||||
// background context needs to cache goes in extension storage instead.
|
||||
|
||||
const { getProvider } = require("./balances");
|
||||
const { log } = require("./log");
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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
|
||||
// phishingBlocklist.json and bundled at build time. At runtime, we fetch
|
||||
// the live list periodically and keep only the delta (new entries not in
|
||||
// the vendored list) in memory. This keeps runtime memory usage small.
|
||||
// The list is produced by script/vendor-blocklist from a hash-pinned upstream
|
||||
// commit, committed as phishingBlocklist.json, and bundled. There is no runtime
|
||||
// fetch: the extension asks nobody anything to answer this question, so no third
|
||||
// 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
|
||||
// sites), then falls back to the vendored list.
|
||||
// The artifact holds digests, not domains: sha256 truncated to 64 bits, one
|
||||
// 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
|
||||
// extension storage, so they survive termination of the MV3 service worker.
|
||||
// Extension storage, not localStorage: localStorage does not exist in a
|
||||
// service worker, so the previous persistence never ran on Chrome at all.
|
||||
// The stored timestamps are what keep a restarted worker from re-fetching on
|
||||
// every wake while still noticing an overdue update. Those guards apply to the
|
||||
// startup path only; the 24-hour alarm tick bypasses them, or it would veto
|
||||
// its own refresh — see updatePhishingList().
|
||||
// - the extension ships no plaintext list of anyone's domain names, which is
|
||||
// what makes a blocklist assembled elsewhere shippable here at all.
|
||||
// - a lookup is a binary search over that string. Nothing is built at module
|
||||
// load, which matters because the MV3 service worker is torn down when idle
|
||||
// and re-evaluates this file on every wake.
|
||||
// - the file is 1.7 MB rather than 8.7 MB.
|
||||
//
|
||||
// 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 =
|
||||
"https://raw.githubusercontent.com/MetaMask/eth-phishing-detect/main/src/config.json";
|
||||
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
// Floor on how often an unscheduled path may hit the network. The worker is
|
||||
// revived every ~30 seconds while the browser is busy, and every revival runs
|
||||
// the startup path; without a persisted record of the last attempt, any state
|
||||
// that leaves lastFetchTime unset — a fetch that failed, or a delta too large
|
||||
// to store — would download the full list on every single wake.
|
||||
const MIN_FETCH_ATTEMPT_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
const DELTA_STORAGE_KEY = "phishing-delta";
|
||||
const 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()),
|
||||
// The artifact is generated, so a shape it does not have is a build fault, not
|
||||
// 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 —
|
||||
// produces a blocklist that matches nothing at all while looking perfectly
|
||||
// healthy. A phishing check that silently answers "no" to everything is the one
|
||||
// failure this module must not have.
|
||||
function checkArtifact(a) {
|
||||
const bad = (why) =>
|
||||
new Error(
|
||||
"phishingBlocklist.json " +
|
||||
why +
|
||||
". It is generated by script/vendor-blocklist; re-run that " +
|
||||
"rather than editing it.",
|
||||
);
|
||||
|
||||
// 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()),
|
||||
if (!a || typeof a !== "object") throw bad("is not an object");
|
||||
if (a.algorithm !== HASH_ALGORITHM) {
|
||||
throw bad(
|
||||
"declares algorithm " +
|
||||
JSON.stringify(a.algorithm) +
|
||||
", but this build hashes with " +
|
||||
HASH_ALGORITHM,
|
||||
);
|
||||
}
|
||||
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)),
|
||||
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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
lastFetchTime = Date.now();
|
||||
return saveDeltaToStorage();
|
||||
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.
|
||||
* 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.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isPhishingDomain(hostname) {
|
||||
if (!hostname) return false;
|
||||
const variants = hostnameVariants(hostname);
|
||||
|
||||
// Check delta blacklist first (fresh/recent scam sites), then vendored
|
||||
for (const v of variants) {
|
||||
if (deltaBlacklist.has(v) || vendoredBlacklist.has(v)) return true;
|
||||
for (const variant of hostnameVariants(hostname)) {
|
||||
if (hashListed(hashDomain(variant))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the latest blocklist and compute delta against vendored data.
|
||||
* 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.
|
||||
* Return the blocklist size for diagnostics.
|
||||
*
|
||||
* @returns {number}
|
||||
*/
|
||||
function getBlocklistSize() {
|
||||
return vendoredBlacklist.size + deltaBlacklist.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
return COUNT;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isPhishingDomain,
|
||||
updatePhishingList,
|
||||
refreshPhishingListOnSchedule,
|
||||
initPhishingList,
|
||||
loadDeltaFromStorage,
|
||||
loadConfig,
|
||||
CACHE_TTL_MS,
|
||||
MIN_FETCH_ATTEMPT_INTERVAL_MS,
|
||||
DELTA_STORAGE_KEY,
|
||||
MAX_DELTA_BYTES,
|
||||
getBlocklistSize,
|
||||
getDeltaSize,
|
||||
hostnameVariants,
|
||||
_reset,
|
||||
// Exposed for testing only
|
||||
_getVendoredBlacklistSize: () => vendoredBlacklist.size,
|
||||
_getDeltaBlacklist: () => deltaBlacklist,
|
||||
// Exposed for testing only: the ends of the search range are where an
|
||||
// off-by-one hides, and reaching them through isPhishingDomain() would mean
|
||||
// knowing which domain hashes to the first or last entry.
|
||||
_hashListed: hashListed,
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ async function refreshPrices() {
|
||||
const fetched = await getTopTokenPrices(25);
|
||||
Object.assign(prices, fetched);
|
||||
lastFetchedAt = now;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// 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");
|
||||
if (currentNetwork().isTestnet) return null;
|
||||
if (!prices.ETH) return null;
|
||||
let total = 0;
|
||||
const ethBal = parseFloat(addr.balance || "0");
|
||||
total += ethBal * prices.ETH;
|
||||
if (currentNetwork().isTestnet) return { usd: null, partial: false };
|
||||
if (!prices.ETH) return { usd: null, partial: false };
|
||||
let usd = parseFloat(addr.balance || "0") * prices.ETH;
|
||||
let partial = false;
|
||||
for (const token of addr.tokenBalances || []) {
|
||||
const tokenBal = parseFloat(token.balance || "0");
|
||||
if (tokenBal > 0 && prices[token.symbol]) {
|
||||
total += tokenBal * prices[token.symbol];
|
||||
// A balance of zero is not a holding: it can neither add to the total
|
||||
// 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) {
|
||||
const { currentNetwork } = require("./state");
|
||||
if (currentNetwork().isTestnet) return null;
|
||||
if (!prices.ETH) return null;
|
||||
let total = 0;
|
||||
for (const addr of wallet.addresses) {
|
||||
total += getAddressValueUsd(addr);
|
||||
}
|
||||
return total;
|
||||
// The same pair for a whole wallet, and for every wallet at once. One
|
||||
// unpriced holding anywhere makes the sum a floor, so partial carries up.
|
||||
function getWalletValue(wallet) {
|
||||
return sumValues(wallet.addresses.map(getAddressValue));
|
||||
}
|
||||
|
||||
function getTotalValueUsd(wallets) {
|
||||
const { currentNetwork } = require("./state");
|
||||
if (currentNetwork().isTestnet) return null;
|
||||
if (!prices.ETH) return null;
|
||||
let total = 0;
|
||||
for (const wallet of wallets) {
|
||||
total += getWalletValueUsd(wallet);
|
||||
function getTotalValue(wallets) {
|
||||
return sumValues(wallets.map(getWalletValue));
|
||||
}
|
||||
return total;
|
||||
|
||||
function sumValues(values) {
|
||||
let usd = null;
|
||||
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 { 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 = {
|
||||
@@ -99,7 +134,8 @@ module.exports = {
|
||||
clearPrices,
|
||||
getPrice,
|
||||
formatUsd,
|
||||
getAddressValueUsd,
|
||||
getWalletValueUsd,
|
||||
getTotalValueUsd,
|
||||
formatAddressTotal,
|
||||
getAddressValue,
|
||||
getWalletValue,
|
||||
getTotalValue,
|
||||
};
|
||||
|
||||
@@ -5,10 +5,7 @@ const { networkById } = require("./networks");
|
||||
// Dependency-free constant module; safe to pull into a background bundle.
|
||||
const { RESTORABLE_VIEWS } = require("../popup/restorableViews");
|
||||
|
||||
const storageApi =
|
||||
typeof browser !== "undefined"
|
||||
? browser.storage.local
|
||||
: chrome.storage.local;
|
||||
const { storageGet, storageSet } = require("./browserApi");
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
hasWallet: false,
|
||||
@@ -114,11 +111,11 @@ async function saveState() {
|
||||
viewData: state.viewData,
|
||||
viewStack: state.viewStack,
|
||||
};
|
||||
await storageApi.set({ autistmask: persisted });
|
||||
await storageSet({ autistmask: persisted });
|
||||
}
|
||||
|
||||
async function loadState() {
|
||||
const result = await storageApi.get("autistmask");
|
||||
const result = await storageGet("autistmask");
|
||||
if (result.autistmask) {
|
||||
const saved = result.autistmask;
|
||||
state.wallets = saved.wallets || [];
|
||||
|
||||
@@ -82,7 +82,7 @@ function toFixedPoint(value) {
|
||||
if (text === "") return null;
|
||||
try {
|
||||
return parseUnits(text, SCALE_DECIMALS);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,11 @@ function decodeV2SwapExactIn(input) {
|
||||
// Decode V2_SWAP_EXACT_OUT (command 0x09) input bytes.
|
||||
// ABI: (address recipient, uint256 amountOut, uint256 amountInMax,
|
||||
// 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) {
|
||||
try {
|
||||
const d = coder.decode(
|
||||
|
||||
@@ -57,7 +57,7 @@ async function cryptoBackend() {
|
||||
try {
|
||||
await WebAssembly.compile(EMPTY_WASM_MODULE);
|
||||
return "wasm";
|
||||
} catch (_) {
|
||||
} catch {
|
||||
return "asmjs";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Wallet and address deletion state transitions, kept out of the views so the
|
||||
// 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
|
||||
// address equality is never a literal string comparison.
|
||||
function sameAddress(a, b) {
|
||||
@@ -144,9 +146,7 @@ function removeAddressFromState(state, walletIdx, addrIdx) {
|
||||
// accountsChanged to connected sites. Same call shape as the address
|
||||
// switch in the home view.
|
||||
function broadcastActiveChanged() {
|
||||
const runtime =
|
||||
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
|
||||
runtime.sendMessage({ type: "AUTISTMASK_ACTIVE_CHANGED" });
|
||||
notify({ type: "AUTISTMASK_ACTIVE_CHANGED" });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
238
tests/addressValue.test.js
Normal file
238
tests/addressValue.test.js
Normal 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(" ");
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -80,17 +80,12 @@ describe("alarms module", () => {
|
||||
delete global.chrome;
|
||||
});
|
||||
|
||||
test("ensureRecurringAlarms schedules both recurring jobs", async () => {
|
||||
test("ensureRecurringAlarms schedules the recurring job", async () => {
|
||||
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();
|
||||
expect(names).toEqual(
|
||||
[
|
||||
alarmsMod.BALANCE_REFRESH_ALARM,
|
||||
alarmsMod.PHISHING_REFRESH_ALARM,
|
||||
].sort(),
|
||||
);
|
||||
const names = alarmsStub.created.map((c) => c.name);
|
||||
expect(names).toEqual([alarmsMod.BALANCE_REFRESH_ALARM]);
|
||||
});
|
||||
|
||||
test("the balance refresh keeps its 60-second cadence", async () => {
|
||||
@@ -99,12 +94,35 @@ describe("alarms module", () => {
|
||||
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();
|
||||
const phishing = alarmsStub.alarms.get(
|
||||
alarmsMod.PHISHING_REFRESH_ALARM,
|
||||
const again = await alarmsMod.ensureRecurringAlarms();
|
||||
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 () => {
|
||||
@@ -122,14 +140,14 @@ describe("alarms module", () => {
|
||||
|
||||
test("a revived worker does not reset an existing alarm's schedule", async () => {
|
||||
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
|
||||
// its period, so a busy extension would push the next fire out
|
||||
// forever and the job would never run.
|
||||
const again = await alarmsMod.ensureRecurringAlarms();
|
||||
expect(again).toEqual({ balance: false, phishing: false });
|
||||
expect(alarmsStub.create).toHaveBeenCalledTimes(2);
|
||||
expect(again).toEqual({ balance: false, cleared: [] });
|
||||
expect(alarmsStub.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
const again = await alarmsMod.ensureRecurringAlarms();
|
||||
expect(again).toEqual({ balance: true, phishing: false });
|
||||
expect(again).toEqual({ balance: true, cleared: [] });
|
||||
expect(
|
||||
alarmsStub.alarms.get(alarmsMod.BALANCE_REFRESH_ALARM),
|
||||
).toBeDefined();
|
||||
@@ -147,17 +165,17 @@ describe("alarms module", () => {
|
||||
// An install carries its alarms across an extension update, so a
|
||||
// period changed in a new release only ever reaches users if the
|
||||
// stale one is reconciled.
|
||||
alarmsStub.create(alarmsMod.PHISHING_REFRESH_ALARM, {
|
||||
alarmsStub.create(alarmsMod.BALANCE_REFRESH_ALARM, {
|
||||
periodInMinutes: 7 * 24 * 60,
|
||||
});
|
||||
alarmsStub.create.mockClear();
|
||||
|
||||
const created = await alarmsMod.ensureRecurringAlarms();
|
||||
expect(created.phishing).toBe(true);
|
||||
expect(created.balance).toBe(true);
|
||||
expect(
|
||||
alarmsStub.alarms.get(alarmsMod.PHISHING_REFRESH_ALARM)
|
||||
alarmsStub.alarms.get(alarmsMod.BALANCE_REFRESH_ALARM)
|
||||
.periodInMinutes,
|
||||
).toBe(alarmsMod.PHISHING_REFRESH_PERIOD_MINUTES);
|
||||
).toBe(alarmsMod.BALANCE_REFRESH_PERIOD_MINUTES);
|
||||
});
|
||||
|
||||
test("reconciling a period settles instead of re-creating forever", async () => {
|
||||
@@ -168,31 +186,31 @@ describe("alarms module", () => {
|
||||
alarmsStub.create.mockClear();
|
||||
|
||||
const again = await alarmsMod.ensureRecurringAlarms();
|
||||
expect(again).toEqual({ balance: false, phishing: false });
|
||||
expect(again).toEqual({ balance: false, cleared: [] });
|
||||
expect(alarmsStub.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handlers are dispatched by alarm name from one listener", () => {
|
||||
const balance = jest.fn();
|
||||
const phishing = jest.fn();
|
||||
const other = jest.fn();
|
||||
expect(
|
||||
alarmsMod.registerAlarmHandlers({
|
||||
[alarmsMod.BALANCE_REFRESH_ALARM]: balance,
|
||||
[alarmsMod.PHISHING_REFRESH_ALARM]: phishing,
|
||||
"autistmask-some-other-job": other,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(alarmsStub.listenerCount()).toBe(1);
|
||||
|
||||
alarmsStub.fire(alarmsMod.BALANCE_REFRESH_ALARM);
|
||||
expect(balance).toHaveBeenCalledTimes(1);
|
||||
expect(phishing).not.toHaveBeenCalled();
|
||||
expect(other).not.toHaveBeenCalled();
|
||||
|
||||
alarmsStub.fire(alarmsMod.PHISHING_REFRESH_ALARM);
|
||||
expect(phishing).toHaveBeenCalledTimes(1);
|
||||
alarmsStub.fire("autistmask-some-other-job");
|
||||
expect(other).toHaveBeenCalledTimes(1);
|
||||
|
||||
alarmsStub.fire("some-other-extension-alarm");
|
||||
alarmsStub.fire("an-alarm-with-no-handler");
|
||||
expect(balance).toHaveBeenCalledTimes(1);
|
||||
expect(phishing).toHaveBeenCalledTimes(1);
|
||||
expect(other).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("Firefox MV2 gets the same treatment via browser.alarms", async () => {
|
||||
@@ -205,8 +223,8 @@ describe("alarms module", () => {
|
||||
try {
|
||||
const mod = require("../src/shared/alarms");
|
||||
const created = await mod.ensureRecurringAlarms();
|
||||
expect(created).toEqual({ balance: true, phishing: true });
|
||||
expect(firefoxAlarms.created).toHaveLength(2);
|
||||
expect(created).toEqual({ balance: true, cleared: [] });
|
||||
expect(firefoxAlarms.created).toHaveLength(1);
|
||||
// The Chrome stub must not have been touched.
|
||||
expect(alarmsStub.create).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
@@ -220,7 +238,7 @@ describe("alarms module", () => {
|
||||
const mod = require("../src/shared/alarms");
|
||||
await expect(mod.ensureRecurringAlarms()).resolves.toEqual({
|
||||
balance: false,
|
||||
phishing: false,
|
||||
cleared: [],
|
||||
});
|
||||
expect(mod.registerAlarmHandlers({})).toBe(false);
|
||||
});
|
||||
@@ -274,9 +292,12 @@ function loadBackground(initialStore = {}) {
|
||||
tabs: { query: jest.fn(), sendMessage: 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 () => ({
|
||||
ok: true,
|
||||
json: async () => ({ blacklist: [] }),
|
||||
json: async () => ({}),
|
||||
}));
|
||||
jest.resetModules();
|
||||
require("../src/background/index");
|
||||
@@ -318,17 +339,21 @@ describe("background worker scheduling", () => {
|
||||
// Let the startup path's promises settle.
|
||||
await settle();
|
||||
|
||||
const names = alarmsStub.created.map((c) => c.name).sort();
|
||||
const {
|
||||
BALANCE_REFRESH_ALARM,
|
||||
PHISHING_REFRESH_ALARM,
|
||||
} = require("../src/shared/alarms");
|
||||
expect(names).toEqual(
|
||||
[BALANCE_REFRESH_ALARM, PHISHING_REFRESH_ALARM].sort(),
|
||||
);
|
||||
const names = alarmsStub.created.map((c) => c.name);
|
||||
const { BALANCE_REFRESH_ALARM } = require("../src/shared/alarms");
|
||||
expect(names).toEqual([BALANCE_REFRESH_ALARM]);
|
||||
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 () => {
|
||||
alarmsStub = loadBackground().alarmsStub;
|
||||
await settle();
|
||||
@@ -348,7 +373,7 @@ describe("background worker scheduling", () => {
|
||||
alarmsStub.created.length = 0;
|
||||
loaded.listeners.onStartup[0]();
|
||||
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 () => {
|
||||
@@ -360,13 +385,10 @@ describe("background worker scheduling", () => {
|
||||
loaded.listeners.onInstalled[0]();
|
||||
await settle();
|
||||
|
||||
expect(alarmsStub.created).toHaveLength(2);
|
||||
expect(alarmsStub.created.map((c) => c.name).sort()).toEqual(
|
||||
[
|
||||
expect(alarmsStub.created).toHaveLength(1);
|
||||
expect(alarmsStub.created.map((c) => c.name)).toEqual([
|
||||
"autistmask-balance-refresh",
|
||||
"autistmask-phishing-refresh",
|
||||
].sort(),
|
||||
);
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -14,8 +14,10 @@ const {
|
||||
assertWithinCeilings,
|
||||
sameAddress,
|
||||
failureIsRetryable,
|
||||
isNonceCollision,
|
||||
describeTxFailure,
|
||||
describeSigningFailure,
|
||||
NONCE_COLLISION_MESSAGE,
|
||||
ALLOWED_TX_TYPES,
|
||||
SERIALIZED_FIELDS,
|
||||
FORBIDDEN_FIELDS,
|
||||
@@ -23,6 +25,7 @@ const {
|
||||
TX_STAGE_SIGN,
|
||||
TX_STAGE_VERIFY,
|
||||
TX_STAGE_BROADCAST,
|
||||
TX_STAGE_NONCE,
|
||||
MAX_GAS_LIMIT,
|
||||
MAX_FEE_PER_GAS,
|
||||
} = require("../src/shared/approvalVerify");
|
||||
@@ -1191,7 +1194,6 @@ describe("signing failure and retry", () => {
|
||||
"already known",
|
||||
"timeout of 30000ms exceeded",
|
||||
"could not coalesce error",
|
||||
"replacement transaction underpriced",
|
||||
]) {
|
||||
const outcome = describeTxFailure(
|
||||
TX_STAGE_BROADCAST,
|
||||
@@ -1199,10 +1201,76 @@ describe("signing failure and retry", () => {
|
||||
);
|
||||
expect(outcome.retryable).toBe(false);
|
||||
expect(outcome.spendApproval).toBe(true);
|
||||
expect(outcome.stage).toBe(TX_STAGE_BROADCAST);
|
||||
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", () => {
|
||||
const outcome = describeSigningFailure(
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -150,7 +150,7 @@ describe("the balance warning on the removal confirmation", () => {
|
||||
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.
|
||||
test("no USD total is shown when prices are not known", () => {
|
||||
expect(balanceWarningHtml(TOKEN_ONLY)).not.toContain("Total:");
|
||||
|
||||
34
tests/e2e/Dockerfile
Normal file
34
tests/e2e/Dockerfile
Normal 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
|
||||
@@ -1,10 +1,24 @@
|
||||
# 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
|
||||
# harness itself has no dependencies, so nothing is installed for it.
|
||||
# with this repo and a freshly built extension inside it, built by
|
||||
# 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
|
||||
# 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.
|
||||
# The build context is the repo root. 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. 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
|
||||
FROM node@sha256:d649c27dae7ba0137b3cef5dd75baa422c08dc3d9e3fc0c23dfb172dc3cc6436
|
||||
@@ -48,4 +62,16 @@ ENV FIREFOX_BIN=/opt/firefox/firefox
|
||||
ENV GECKODRIVER=/usr/local/bin/geckodriver
|
||||
|
||||
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"]
|
||||
|
||||
238
tests/e2e/firefox/dapp.js
Normal file
238
tests/e2e/firefox/dapp.js
Normal 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,
|
||||
};
|
||||
@@ -88,7 +88,7 @@ class Driver {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch (_) {
|
||||
} catch {
|
||||
throw new Error(
|
||||
method + " " + path + ": non-JSON response: " + text,
|
||||
);
|
||||
@@ -110,6 +110,26 @@ class Driver {
|
||||
"extensions.webextensions.uuids": JSON.stringify({
|
||||
[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", {
|
||||
@@ -179,6 +199,16 @@ class Driver {
|
||||
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.
|
||||
async executeChrome(script, args = []) {
|
||||
await this.setContext("chrome");
|
||||
@@ -199,7 +229,9 @@ class Driver {
|
||||
// condition it was waiting on rather than "timed out".
|
||||
async waitFor(what, script, args = [], timeout = DEFAULT_WAIT_MS) {
|
||||
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 (;;) {
|
||||
try {
|
||||
const v = await this.execute(script, args);
|
||||
@@ -329,6 +361,63 @@ class Driver {
|
||||
[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
|
||||
@@ -349,10 +438,15 @@ class Driver {
|
||||
// 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
|
||||
// src/background/index.js, which kills the background page outright, fails
|
||||
// the run. Content-script errors should arrive by the same route, but that
|
||||
// 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
|
||||
// injected into and this suite never exercises one.
|
||||
// the run.
|
||||
//
|
||||
// Content scripts ARE now exercised: tests/e2e/firefox/dapp.js serves a page
|
||||
// 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:
|
||||
// uncaught errors only.
|
||||
@@ -420,7 +514,7 @@ async function waitForDriverReady(base, timeoutMs) {
|
||||
const body = await res.json();
|
||||
if (body && body.value && body.value.ready !== false) return;
|
||||
}
|
||||
} catch (_) {
|
||||
} catch {
|
||||
// not listening yet
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
|
||||
@@ -15,8 +15,15 @@
|
||||
// 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
|
||||
// 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
|
||||
// this suite grows to where they do.
|
||||
// abstract over. Duplicated steps do not pay for a shim; revisit if this
|
||||
// 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
|
||||
// capture here is POLL-BASED, not event-streamed. The console service is
|
||||
@@ -40,7 +47,21 @@
|
||||
const fs = require("fs");
|
||||
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 { startDappServer } = require("./dapp");
|
||||
const { STUB_COUNTERPARTY } = require("../network");
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
|
||||
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");
|
||||
});
|
||||
|
||||
// ------------------------------------------------- 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
|
||||
|
||||
// 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) {
|
||||
return (
|
||||
e.msg + " (" + e.src + ":" + e.line + (e.cat ? ", " + e.cat : "") + ")"
|
||||
@@ -165,6 +776,21 @@ async function main() {
|
||||
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;
|
||||
try {
|
||||
driver = await start();
|
||||
@@ -175,12 +801,20 @@ async function main() {
|
||||
// absent suite. Never skip and report success.
|
||||
console.error("e2e-firefox: cannot run the suite: " + e.message);
|
||||
if (driver) await driver.quit().catch(() => {});
|
||||
await server.close();
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
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("1.." + steps.length);
|
||||
@@ -232,6 +866,20 @@ async function main() {
|
||||
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
|
||||
// that provoked it, whether or not its assertions passed.
|
||||
if (!failure && found.length > 0) {
|
||||
@@ -256,7 +904,13 @@ async function main() {
|
||||
// blamed on any one step, but they are still reported and they
|
||||
// still fail the run.
|
||||
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(
|
||||
"# " +
|
||||
(steps.length - failed) +
|
||||
@@ -273,12 +927,25 @@ async function main() {
|
||||
);
|
||||
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) {
|
||||
console.log("# FAILED");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} finally {
|
||||
await driver.quit().catch(() => {});
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const { chromium } = require("playwright-core");
|
||||
const { installNetworkStubs } = require("./network");
|
||||
const { installNetworkStubs, WORKER_PROBE_URL } = require("./network");
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, "..", "..");
|
||||
const EXT_PATH = path.join(REPO_ROOT, "dist", "chrome");
|
||||
@@ -129,42 +129,109 @@ function attachErrorListeners(ctx, errors) {
|
||||
// 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) {
|
||||
const [existing] = ctx.serviceWorkers();
|
||||
if (existing) return existing;
|
||||
const workers = ctx.serviceWorkers();
|
||||
const latest = workers[workers.length - 1];
|
||||
if (latest) return latest;
|
||||
return ctx.waitForEvent("serviceworker", { timeout: 30000 });
|
||||
}
|
||||
|
||||
// How long to wait for the background worker's first outbound request.
|
||||
//
|
||||
// The margin that actually decides whether this check is sound is not
|
||||
// this timeout — it is whether the route handler is installed before the
|
||||
// worker fetches. Measured over several runs: route installation
|
||||
// completes 11-23ms after the context comes up, and the worker's
|
||||
// blocklist fetch arrives 525-883ms after that, so the route wins by
|
||||
// roughly 25-50x. This 30s figure is only slack for a loaded machine on
|
||||
// top of that; losing the race fails the run rather than passing it
|
||||
// quietly, which was verified by forcing a 3s delay before route
|
||||
// installation.
|
||||
// How long to wait for the probe request the worker is asked to make.
|
||||
const WORKER_TRAFFIC_TIMEOUT_MS = 30000;
|
||||
|
||||
// ctx.route() only sees service-worker requests when Playwright runs with
|
||||
// PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1, which script/test-e2e
|
||||
// sets. Without it the worker's traffic — notably the phishing blocklist
|
||||
// fetch src/background/index.js issues at startup — goes to the real
|
||||
// internet, and nothing says so, because src/shared/phishingDomains.js
|
||||
// swallows fetch failures. A harness whose isolation can lapse in silence
|
||||
// is worthless, so this does not take the flag on trust: the background
|
||||
// worker's own startup fetch has to show up in the route handler, or the
|
||||
// suite refuses to run.
|
||||
// sets. Without it every fetch the background worker makes goes to the
|
||||
// real internet and nothing says so. A harness whose isolation can lapse
|
||||
// in silence is worthless, so this does not take the flag on trust: a
|
||||
// request the worker itself issues has to show up in the route handler,
|
||||
// or the suite refuses to run.
|
||||
//
|
||||
// Deliberately NOT a synthetic probe fetched through worker.evaluate():
|
||||
// evaluating in an extension worker this early kills it (the call fails
|
||||
// with "Target page, context or browser has been closed" and the worker
|
||||
// disappears), which would break the very thing being measured. Observing
|
||||
// traffic the extension already generates costs nothing and cannot
|
||||
// perturb it.
|
||||
async function assertWorkerTrafficIntercepted(stubs) {
|
||||
// The anchor is a probe the harness asks the worker for, not traffic the
|
||||
// extension generates on its own. It used to be the phishing blocklist
|
||||
// fetch src/background/index.js issued at startup; that fetch is gone —
|
||||
// the blocklist is vendored at build time and the extension contacts
|
||||
// nobody when it starts — so there is no longer any startup traffic to
|
||||
// observe and the check generates its own.
|
||||
//
|
||||
// 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(
|
||||
WORKER_TRAFFIC_TIMEOUT_MS,
|
||||
);
|
||||
@@ -177,19 +244,16 @@ async function assertWorkerTrafficIntercepted(stubs) {
|
||||
throw new Error(
|
||||
"observed no service-worker request in the route handler within " +
|
||||
WORKER_TRAFFIC_TIMEOUT_MS +
|
||||
"ms. Under working interception the background worker's " +
|
||||
"startup blocklist fetch (src/background/index.js) reaches the " +
|
||||
"handler about half a second after the route is installed. " +
|
||||
"Two causes are plausible and this check cannot distinguish " +
|
||||
"ms, although the background worker was asked to fetch " +
|
||||
WORKER_PROBE_URL +
|
||||
". Two causes are plausible and this check cannot distinguish " +
|
||||
"them: (1) service-worker interception is not in effect, so " +
|
||||
"that traffic went to the real internet unobserved — the suite " +
|
||||
"that request went to the real internet unobserved — the suite " +
|
||||
"must be run through script/test-e2e, which sets " +
|
||||
"PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1, and a " +
|
||||
"Playwright upgrade may have dropped or renamed that flag; " +
|
||||
"(2) no worker request was made in the first place — the route " +
|
||||
"lost the startup race, or the worker no longer fetches at " +
|
||||
"startup, in which case this check needs a new anchor because " +
|
||||
"there is no longer any worker traffic to observe. Either way " +
|
||||
"(2) the probe never ran, because the worker was torn down " +
|
||||
"between being handed over and being evaluated in. Either way " +
|
||||
"the fix is a replacement mechanism or an honest downgrade of " +
|
||||
"the isolation claims in tests/e2e/network.js and README.md — " +
|
||||
"not deleting this check",
|
||||
@@ -241,7 +305,7 @@ async function launch(routeOpts) {
|
||||
routeOpts.report = (text) => errors.record("network", text);
|
||||
const stubs = await installNetworkStubs(ctx, routeOpts);
|
||||
|
||||
await assertWorkerTrafficIntercepted(stubs);
|
||||
await assertWorkerTrafficIntercepted(ctx, stubs);
|
||||
|
||||
// The extension id is derived from the unpacked path, so it
|
||||
// changes and must never be hardcoded. It is the host part of the
|
||||
@@ -287,7 +351,7 @@ async function pageCompilesWasm(page) {
|
||||
try {
|
||||
await WebAssembly.compile(new Uint8Array(bytes));
|
||||
return true;
|
||||
} catch (_) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, EMPTY_WASM_MODULE);
|
||||
|
||||
@@ -9,13 +9,12 @@
|
||||
//
|
||||
// Service-worker coverage is not free: ctx.route() only sees worker
|
||||
// traffic when PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1 is set in
|
||||
// the environment, which script/test-e2e does. Without it the phishing
|
||||
// blocklist fetch that src/background/index.js issues at worker startup
|
||||
// silently reaches raw.githubusercontent.com on the open internet, and
|
||||
// src/shared/phishingDomains.js swallows the failure so nothing surfaces
|
||||
// it. That is not left to trust: waitForServiceWorkerTraffic() below
|
||||
// backs the launch-time canary in harness.js, which fails the entire
|
||||
// suite if worker requests stop being visible here.
|
||||
// the environment, which script/test-e2e does. Without it every fetch the
|
||||
// MV3 background worker makes — the JSON-RPC calls behind every approval
|
||||
// in this suite among them — goes to the real internet unobserved. That is
|
||||
// not left to trust: waitForServiceWorkerTraffic() below backs the
|
||||
// launch-time canary in harness.js, which fails the entire suite if worker
|
||||
// requests stop being visible here.
|
||||
//
|
||||
// Anything not explicitly stubbed here is aborted AND reported to the
|
||||
// error collector, so a newly added outbound call shows up as a test
|
||||
@@ -46,9 +45,24 @@ const STUB_TX_HASH =
|
||||
|
||||
const STUB_BLOCK_NUMBER = 21000000;
|
||||
|
||||
// The native ETH transfer, seeded by opts.seedNativeTransfer. Its own hash
|
||||
// and an older block, so it is a second row rather than a leg of the token
|
||||
// transfer: mergeTransactions() consolidates a native entry and a token
|
||||
// transfer that share a hash into one row, which would leave nothing native
|
||||
// to open. 0.25 ETH clears the 100000 gwei dust threshold the default
|
||||
// filters apply, so the row is not silently dropped.
|
||||
const STUB_NATIVE_TX_HASH =
|
||||
"0xe7e0000000000000000000000000000000000000000000000000000000000e7e";
|
||||
|
||||
const STUB_NATIVE_BLOCK_NUMBER = STUB_BLOCK_NUMBER - 1;
|
||||
|
||||
const STUB_NATIVE_VALUE_WEI = "250000000000000000";
|
||||
|
||||
// Fixed instant so timeAgo() output is stable across runs.
|
||||
const STUB_TX_TIMESTAMP = "2026-01-02T03:04:05.000000Z";
|
||||
|
||||
const STUB_NATIVE_TX_TIMESTAMP = "2026-01-02T02:03:04.000000Z";
|
||||
|
||||
// A 32-byte zero word. Returned for every eth_call, which is what makes
|
||||
// ethers' ENS reverse lookup resolve to "no resolver set" and return null
|
||||
// instead of throwing. A throw would be logged by src/shared/ens.js via
|
||||
@@ -78,9 +92,32 @@ function word(value) {
|
||||
// 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
|
||||
// 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_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
|
||||
// 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
|
||||
@@ -258,6 +295,25 @@ function tokenTransferItems(address) {
|
||||
];
|
||||
}
|
||||
|
||||
// One received native ETH transfer, in the shape src/shared/transactions.js
|
||||
// parses. to.is_contract is false and there is no method, so parseTx() keeps
|
||||
// it a plain transfer rather than a contract call — which is what makes the
|
||||
// detail screen classify it "Native ETH Transfer" and leave the token
|
||||
// contract row hidden.
|
||||
function nativeTransactionItems(address) {
|
||||
return [
|
||||
{
|
||||
hash: STUB_NATIVE_TX_HASH,
|
||||
block_number: STUB_NATIVE_BLOCK_NUMBER,
|
||||
timestamp: STUB_NATIVE_TX_TIMESTAMP,
|
||||
from: { hash: STUB_COUNTERPARTY },
|
||||
to: { hash: address, is_contract: false },
|
||||
value: STUB_NATIVE_VALUE_WEI,
|
||||
status: "ok",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// A holding of 1.5 E2E, in the shape src/shared/balances.js parses. Serving
|
||||
// this is what puts an ERC-20 in the send screen's token dropdown, which is
|
||||
// the only way the confirmation screen's ERC-20 path can be reached.
|
||||
@@ -270,12 +326,17 @@ function tokenBalanceItems() {
|
||||
];
|
||||
}
|
||||
|
||||
// Full details for STUB_TX_HASH. raw_input is "0x" so the calldata
|
||||
// decoder short-circuits; the on-chain detail fields still populate.
|
||||
function transactionDetails() {
|
||||
// Full details for either seeded transaction — the detail screen fetches
|
||||
// them for whichever row was opened, and an unstubbed hash would be
|
||||
// reported as escaping traffic. raw_input is "0x" so the calldata decoder
|
||||
// short-circuits; the on-chain detail fields still populate.
|
||||
function transactionDetails(hash) {
|
||||
return {
|
||||
hash: STUB_TX_HASH,
|
||||
block_number: STUB_BLOCK_NUMBER,
|
||||
hash: hash,
|
||||
block_number:
|
||||
hash === STUB_NATIVE_TX_HASH
|
||||
? STUB_NATIVE_BLOCK_NUMBER
|
||||
: STUB_BLOCK_NUMBER,
|
||||
nonce: 7,
|
||||
gas_used: "51000",
|
||||
gas_price: "1000000000",
|
||||
@@ -479,6 +540,10 @@ function traceEnabled(raw) {
|
||||
* @param {boolean} [opts.seedTokenTransfer] serve the stubbed ERC-20
|
||||
* transfer. Read at request time, so a test can flip it on the same
|
||||
* options object without re-registering the route.
|
||||
* @param {boolean} [opts.seedNativeTransfer] serve the stubbed native ETH
|
||||
* transfer, read at request time like seedTokenTransfer. Without it the
|
||||
* normal-transactions endpoint answers with an empty list, so there is no
|
||||
* non-ERC-20 row to open.
|
||||
* @param {boolean} [opts.seedTokenBalance] serve the stubbed ERC-20
|
||||
* holding, which is what makes the token reachable from the send screen.
|
||||
* @param {string} [opts.ethBalanceWei] hex wei answered to eth_getBalance;
|
||||
@@ -505,10 +570,9 @@ async function installNetworkStubs(ctx, opts) {
|
||||
// E2E_TRACE_NETWORK=1 prints every request that reaches this handler,
|
||||
// tagged [sw] when it originated in the background service worker.
|
||||
// It exists so the isolation claim above can be re-checked by anyone
|
||||
// in one command, without editing files: the phishing blocklist fetch
|
||||
// showing up with an [sw] tag is the proof that the worker really is
|
||||
// intercepted and that the raw.githubusercontent.com stub below is
|
||||
// live code rather than decoration.
|
||||
// in one command, without editing files: the canary probe and then
|
||||
// every JSON-RPC call behind an approval showing up with an [sw] tag
|
||||
// is the proof that the worker really is intercepted.
|
||||
const trace = traceEnabled(process.env.E2E_TRACE_NETWORK);
|
||||
|
||||
// Regex rather than a glob so chrome-extension:// resource loads are
|
||||
@@ -539,7 +603,11 @@ async function installNetworkStubs(ctx, opts) {
|
||||
// trips run against a real http(s) origin — which is what makes the
|
||||
// shipped content scripts inject at all — without any remote origin
|
||||
// being involved.
|
||||
if (url.origin === DAPP_ORIGIN && p === "/") {
|
||||
if (
|
||||
(url.origin === DAPP_ORIGIN ||
|
||||
url.origin === PHISHING_DAPP_ORIGIN) &&
|
||||
p === "/"
|
||||
) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/html; charset=utf-8",
|
||||
@@ -550,7 +618,13 @@ async function installNetworkStubs(ctx, opts) {
|
||||
// Blockscout v2
|
||||
if (p.includes("/api/v2/")) {
|
||||
if (/\/addresses\/0x[0-9a-fA-F]{40}\/transactions$/.test(p)) {
|
||||
return jsonResponse(route, { items: [] });
|
||||
const addr = blockscoutAddress(p);
|
||||
return jsonResponse(route, {
|
||||
items:
|
||||
opts.seedNativeTransfer && addr
|
||||
? nativeTransactionItems(addr)
|
||||
: [],
|
||||
});
|
||||
}
|
||||
if (/\/addresses\/0x[0-9a-fA-F]{40}\/token-transfers$/.test(p)) {
|
||||
const addr = blockscoutAddress(p);
|
||||
@@ -567,8 +641,10 @@ async function installNetworkStubs(ctx, opts) {
|
||||
opts.seedTokenBalance ? tokenBalanceItems() : [],
|
||||
);
|
||||
}
|
||||
if (p.endsWith("/transactions/" + STUB_TX_HASH)) {
|
||||
return jsonResponse(route, transactionDetails());
|
||||
for (const hash of [STUB_TX_HASH, STUB_NATIVE_TX_HASH]) {
|
||||
if (p.endsWith("/transactions/" + hash)) {
|
||||
return jsonResponse(route, transactionDetails(hash));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -577,18 +653,10 @@ async function installNetworkStubs(ctx, opts) {
|
||||
return jsonResponse(route, { Data: {} });
|
||||
}
|
||||
|
||||
// MetaMask phishing blocklist
|
||||
if (
|
||||
url.hostname === "raw.githubusercontent.com" ||
|
||||
p.endsWith("/eth-phishing-detect/main/src/config.json")
|
||||
) {
|
||||
return jsonResponse(route, {
|
||||
version: 2,
|
||||
tolerance: 2,
|
||||
fuzzylist: [],
|
||||
whitelist: [],
|
||||
blacklist: [],
|
||||
});
|
||||
// The interception canary's own request. Answered with nothing: what
|
||||
// is being observed is that it arrived here at all.
|
||||
if (url.href === WORKER_PROBE_URL) {
|
||||
return route.fulfill({ status: 204, body: "" });
|
||||
}
|
||||
|
||||
// Best-effort Etherscan address labels: served as an empty page.
|
||||
@@ -609,12 +677,12 @@ async function installNetworkStubs(ctx, opts) {
|
||||
* Resolve with the first service-worker-originated request this
|
||||
* handler saw, or null if none arrives within `ms`.
|
||||
*
|
||||
* The background worker fetches the phishing blocklist at
|
||||
* startup, unconditionally, within about a second of the context
|
||||
* coming up — so under working interception this resolves almost
|
||||
* immediately. Nothing arriving means worker traffic is bypassing
|
||||
* the handler entirely and going to the real internet, which the
|
||||
* caller turns into a hard failure of the whole suite.
|
||||
* The caller asks the worker for one request of its own (see
|
||||
* WORKER_PROBE_URL) and then waits here, so under working
|
||||
* interception this resolves almost immediately. Nothing arriving
|
||||
* means worker traffic is bypassing the handler entirely and going
|
||||
* to the real internet, which the caller turns into a hard failure
|
||||
* of the whole suite.
|
||||
*/
|
||||
waitForServiceWorkerTraffic(ms) {
|
||||
if (firstWorkerRequest) return Promise.resolve(firstWorkerRequest);
|
||||
@@ -635,11 +703,17 @@ async function installNetworkStubs(ctx, opts) {
|
||||
|
||||
module.exports = {
|
||||
installNetworkStubs,
|
||||
DAPP_HTML,
|
||||
DAPP_ORIGIN,
|
||||
DAPP_URL,
|
||||
PHISHING_DAPP_ORIGIN,
|
||||
PHISHING_DAPP_URL,
|
||||
WORKER_PROBE_URL,
|
||||
FEE_ESTIMATE_WEI,
|
||||
FEE_RESERVE_WEI,
|
||||
STUB_COUNTERPARTY,
|
||||
STUB_NATIVE_TX_HASH,
|
||||
STUB_NATIVE_VALUE_WEI,
|
||||
STUB_TOKEN,
|
||||
STUB_TX_HASH,
|
||||
};
|
||||
|
||||
792
tests/e2e/run.js
792
tests/e2e/run.js
@@ -33,13 +33,17 @@ const {
|
||||
const {
|
||||
DAPP_ORIGIN,
|
||||
DAPP_URL,
|
||||
PHISHING_DAPP_URL,
|
||||
FEE_ESTIMATE_WEI,
|
||||
FEE_RESERVE_WEI,
|
||||
STUB_COUNTERPARTY,
|
||||
STUB_NATIVE_TX_HASH,
|
||||
STUB_NATIVE_VALUE_WEI,
|
||||
STUB_TOKEN,
|
||||
STUB_TX_HASH,
|
||||
} = require("./network");
|
||||
const { DUST_THRESHOLD_MESSAGE } = require("../../src/popup/dustThreshold");
|
||||
const { NETWORKS } = require("../../src/shared/networks");
|
||||
|
||||
const TEST_TIMEOUT_MS = 120000;
|
||||
|
||||
@@ -66,7 +70,11 @@ function withTimeout(promise, name) {
|
||||
const timeout = new Promise((_, reject) => {
|
||||
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,
|
||||
);
|
||||
});
|
||||
@@ -169,6 +177,264 @@ test("transaction detail renders an ERC-20 transfer (#151)", async (env) => {
|
||||
assert(dots > 0, "token contract row rendered without its colour dot");
|
||||
});
|
||||
|
||||
// --------------------- the rest of the #150 and #151 definition of done
|
||||
//
|
||||
// The two tests above assert that the screens #150 and #151 broke now open
|
||||
// without throwing, which is narrower than what those issues asked for.
|
||||
// The four items below are the remainder (#188): the navigation stack out
|
||||
// of Add Token, the quick-pick actually populating the field, the native
|
||||
// ETH detail path the ERC-20 fix could have regressed, and tap-to-copy.
|
||||
|
||||
// Leave the transaction detail screen for the address screen it was opened
|
||||
// from. The two tests above finish on it, and so does the last test here.
|
||||
async function leaveTransactionDetail(page) {
|
||||
if (await page.isVisible("#view-transaction")) {
|
||||
await page.click("#btn-tx-back");
|
||||
}
|
||||
await openAddressDetail(page);
|
||||
}
|
||||
|
||||
// Back out to Home from wherever the previous test finished.
|
||||
async function goHome(page) {
|
||||
await leaveTransactionDetail(page);
|
||||
await page.click("#btn-address-back");
|
||||
await visible(page, "#view-main");
|
||||
}
|
||||
|
||||
// The navigation stack as it was actually persisted, read out of extension
|
||||
// storage rather than inferred from which screen is showing. A stale entry
|
||||
// left behind by a forward navigation that threw is invisible on screen
|
||||
// until the user presses Back one time too many — which is exactly the
|
||||
// second-order damage #150 did — so the stack itself is what gets asserted.
|
||||
function persistedViewStack(page) {
|
||||
return page.evaluate(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
chrome.storage.local.get("autistmask", (r) => {
|
||||
resolve((r.autistmask && r.autistmask.viewStack) || []);
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// saveState() is fired from showView() without being awaited, so the write
|
||||
// lands shortly after the screen does. Polling for the expected stack keeps
|
||||
// that race out of the assertion; a stack that never becomes the expected
|
||||
// one fails with what it actually was.
|
||||
const VIEW_STACK_SETTLE_MS = 5000;
|
||||
|
||||
async function waitForViewStack(page, expected, where) {
|
||||
const want = JSON.stringify(expected);
|
||||
const deadline = Date.now() + VIEW_STACK_SETTLE_MS;
|
||||
let seen;
|
||||
for (;;) {
|
||||
seen = await persistedViewStack(page);
|
||||
if (JSON.stringify(seen) === want) return;
|
||||
if (Date.now() >= deadline) break;
|
||||
await sleep(50);
|
||||
}
|
||||
throw new Error(
|
||||
"navigation stack " +
|
||||
where +
|
||||
" is " +
|
||||
JSON.stringify(seen) +
|
||||
", expected " +
|
||||
want,
|
||||
);
|
||||
}
|
||||
|
||||
// The invariant is stated as a delta against whatever the earlier tests
|
||||
// left on the stack, not as an absolute: a round trip into Add Token and
|
||||
// back out must leave the stack exactly as it found it. That is what "no
|
||||
// duplicated or orphaned stack entry" means, and it holds whatever the
|
||||
// starting depth is.
|
||||
test("Back from Add Token unwinds the stack exactly once (#150)", async (env) => {
|
||||
await goHome(env.page);
|
||||
const base = await persistedViewStack(env.page);
|
||||
|
||||
await env.page.locator("#wallet-list .btn-addr-info").first().click();
|
||||
await visible(env.page, "#view-address");
|
||||
await waitForViewStack(env.page, base.concat("main"), "on address detail");
|
||||
|
||||
await env.page.click("#btn-add-token");
|
||||
await visible(env.page, "#view-add-token");
|
||||
await waitForViewStack(
|
||||
env.page,
|
||||
base.concat("main", "address"),
|
||||
"on the add token screen",
|
||||
);
|
||||
|
||||
await env.page.click("#btn-add-token-back");
|
||||
await visible(env.page, "#view-address");
|
||||
assert(
|
||||
!(await env.page.isVisible("#view-add-token")),
|
||||
"the add token screen is still showing after Back",
|
||||
);
|
||||
await waitForViewStack(
|
||||
env.page,
|
||||
base.concat("main"),
|
||||
"after Back from add token",
|
||||
);
|
||||
|
||||
await env.page.click("#btn-address-back");
|
||||
await visible(env.page, "#view-main");
|
||||
await waitForViewStack(env.page, base, "after a second Back");
|
||||
});
|
||||
|
||||
test("a common-token quick-pick fills in the contract address (#150)", async (env) => {
|
||||
await openAddressDetail(env.page);
|
||||
await env.page.click("#btn-add-token");
|
||||
await visible(env.page, "#view-add-token");
|
||||
|
||||
const before = await env.page.inputValue("#add-token-address");
|
||||
assert(
|
||||
before === "",
|
||||
"the add token screen opened with the address field already filled: " +
|
||||
JSON.stringify(before),
|
||||
);
|
||||
|
||||
const pick = env.page.locator("#common-token-list .common-token").first();
|
||||
const wanted = await pick.getAttribute("data-address");
|
||||
assert(
|
||||
/^0x[0-9a-fA-F]{40}$/.test(wanted || ""),
|
||||
"the first quick-pick button carries no contract address: " +
|
||||
JSON.stringify(wanted),
|
||||
);
|
||||
|
||||
await pick.click();
|
||||
const after = await env.page.inputValue("#add-token-address");
|
||||
assert(
|
||||
after === wanted,
|
||||
"clicking the " +
|
||||
(await pick.innerText()).trim() +
|
||||
" quick-pick left the address field as " +
|
||||
JSON.stringify(after) +
|
||||
", expected " +
|
||||
JSON.stringify(wanted),
|
||||
);
|
||||
|
||||
await env.page.click("#btn-add-token-back");
|
||||
await visible(env.page, "#view-address");
|
||||
});
|
||||
|
||||
// The native amount as the transaction list writes it (four decimals) and
|
||||
// as the detail screen writes it (full precision). Both are rendered here
|
||||
// from the fixture rather than read off the screen, so the assertions
|
||||
// compare against the wei the stub served.
|
||||
const NATIVE_ROW_TEXT =
|
||||
parseFloat(formatEther(STUB_NATIVE_VALUE_WEI)).toFixed(4) + " ETH";
|
||||
const NATIVE_DETAIL_TEXT = formatEther(STUB_NATIVE_VALUE_WEI) + " ETH";
|
||||
|
||||
test("the native ETH transaction detail still renders (#151)", async (env) => {
|
||||
// The ERC-20 fix could only have regressed this path by making the
|
||||
// token-contract branch run for a transfer that has no contract, so
|
||||
// the assertions below are as much about that row staying hidden as
|
||||
// about the screen coming up.
|
||||
env.routeOpts.seedNativeTransfer = true;
|
||||
await env.page.reload();
|
||||
await openAddressDetail(env.page);
|
||||
|
||||
const row = env.page
|
||||
.locator("#tx-list .tx-row")
|
||||
.filter({ hasText: NATIVE_ROW_TEXT });
|
||||
await row.waitFor({ state: "visible", timeout: 30000 });
|
||||
await row.click();
|
||||
await visible(env.page, "#view-transaction");
|
||||
|
||||
const hash = await env.page.locator("#tx-detail-hash").innerText();
|
||||
assert(
|
||||
hash.includes(STUB_NATIVE_TX_HASH),
|
||||
"the native transaction detail shows the wrong hash: " + hash,
|
||||
);
|
||||
|
||||
const type = (await env.page.locator("#tx-detail-type").innerText()).trim();
|
||||
assert(
|
||||
type === "Native ETH Transfer",
|
||||
"the native transaction was classified " + JSON.stringify(type),
|
||||
);
|
||||
|
||||
const value = await env.page.locator("#tx-detail-value").innerText();
|
||||
assert(
|
||||
value.includes(NATIVE_DETAIL_TEXT),
|
||||
"the native transaction detail shows " +
|
||||
JSON.stringify(value) +
|
||||
", expected it to contain " +
|
||||
NATIVE_DETAIL_TEXT,
|
||||
);
|
||||
|
||||
const native = await env.page.locator("#tx-detail-native").innerText();
|
||||
assert(
|
||||
native.includes(STUB_NATIVE_VALUE_WEI + " wei"),
|
||||
"the raw quantity row shows " +
|
||||
JSON.stringify(native) +
|
||||
", expected the value in wei",
|
||||
);
|
||||
|
||||
assert(
|
||||
!(await env.page.isVisible("#tx-detail-token-contract-section")),
|
||||
"the token contract row is showing on a transfer that has no token " +
|
||||
"contract",
|
||||
);
|
||||
|
||||
// Back to one seeded transaction for everything after this: the tests
|
||||
// below were written against a list holding the token transfer alone.
|
||||
env.routeOpts.seedNativeTransfer = false;
|
||||
});
|
||||
|
||||
test("tap-to-copy on the transaction detail screen copies the address (#151)", async (env) => {
|
||||
// Read the clipboard back rather than watching the handler run: what
|
||||
// #151 asks for is the address reaching the clipboard, and a spy on
|
||||
// navigator.clipboard would assert the call and not the effect.
|
||||
//
|
||||
// Granted context-wide rather than for the popup's origin: an
|
||||
// origin-scoped grant is refused for chrome-extension: URLs, which
|
||||
// both Playwright and Chrome treat as opaque here.
|
||||
await env.ctx.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
|
||||
await leaveTransactionDetail(env.page);
|
||||
const row = env.page
|
||||
.locator("#tx-list .tx-row")
|
||||
.filter({ hasText: STUB_TOKEN.symbol });
|
||||
await row.waitFor({ state: "visible", timeout: 30000 });
|
||||
await row.click();
|
||||
await visible(env.page, "#view-transaction");
|
||||
await visible(env.page, "#tx-detail-token-contract-section");
|
||||
|
||||
// Seed a sentinel first, so a clipboard that nothing writes to cannot
|
||||
// pass on whatever was left in it.
|
||||
const SENTINEL = "e2e-clipboard-untouched";
|
||||
await env.page.evaluate((s) => navigator.clipboard.writeText(s), SENTINEL);
|
||||
const seeded = await env.page.evaluate(() =>
|
||||
navigator.clipboard.readText(),
|
||||
);
|
||||
assert(
|
||||
seeded === SENTINEL,
|
||||
"the harness could not seed the clipboard, so the assertion below " +
|
||||
"would prove nothing; it read back " +
|
||||
JSON.stringify(seeded),
|
||||
);
|
||||
|
||||
await env.page.locator("#tx-detail-token-contract [data-copy]").click();
|
||||
|
||||
const copied = await env.page.evaluate(() =>
|
||||
navigator.clipboard.readText(),
|
||||
);
|
||||
assert(
|
||||
copied.toLowerCase() === STUB_TOKEN.address,
|
||||
"tapping the token contract address put " +
|
||||
JSON.stringify(copied) +
|
||||
" on the clipboard, expected " +
|
||||
STUB_TOKEN.address,
|
||||
);
|
||||
|
||||
const flash = await env.page.locator("#flash-msg").innerText();
|
||||
assert(
|
||||
flash.trim() === "Copied!",
|
||||
"the copy gave no confirmation, flash line reads " +
|
||||
JSON.stringify(flash),
|
||||
);
|
||||
});
|
||||
|
||||
// -------------------------------------------- recovery phrase (#161)
|
||||
|
||||
// The gear toggles, so pressing it while Settings is already up leaves it.
|
||||
@@ -680,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)
|
||||
|
||||
// The popup size README documents the UI as designed for. Pages in this
|
||||
@@ -1482,9 +2070,9 @@ async function extensionActiveAddress(page) {
|
||||
return getAddress(address);
|
||||
}
|
||||
|
||||
async function openDapp(ctx) {
|
||||
async function openDapp(ctx, url = DAPP_URL) {
|
||||
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
|
||||
// MAIN-world content script. Waiting for it is waiting for the real
|
||||
// provider to have injected itself into a real http(s) origin.
|
||||
@@ -1596,32 +2184,13 @@ async function reserveApprovalTab(env) {
|
||||
// one down with it.
|
||||
env.approvalTab = await env.ctx.newPage();
|
||||
|
||||
// The one accommodation this section makes to the shipped code, and the
|
||||
// reason for it.
|
||||
//
|
||||
// Both approval buttons call runtime.sendMessage() and then window.close()
|
||||
// on the next line. Closing this page disconnects the approval port, and
|
||||
// the disconnect handler in src/background/index.js settles a pending
|
||||
// site approval as a rejection. In a tab those two race and the teardown
|
||||
// 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 () {};
|
||||
});
|
||||
// This tab runs the shipped popup with nothing patched. The site
|
||||
// approval buttons decide and then close on the next line, and the two
|
||||
// site-approval tests below are therefore the real-browser
|
||||
// approve-then-immediate-close and reject-then-immediate-close cases: the
|
||||
// decision rides the approval port, which also carries the disconnect the
|
||||
// close causes, so it is delivered ahead of it and the outcome does not
|
||||
// depend on the teardown timing (#275).
|
||||
await env.approvalTab.goto("about:blank");
|
||||
await sleep(APPROVAL_TAB_SETTLE_MS);
|
||||
return env.approvalTab;
|
||||
@@ -1670,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.
|
||||
//
|
||||
// This is the direct observation the password check needs. It is installed
|
||||
@@ -1886,11 +2544,24 @@ test("eth_requestAccounts rejected at the prompt returns a rejection (#183)", as
|
||||
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
|
||||
// origin in deniedSites and every later test in this section is
|
||||
// auto-rejected with no prompt at all, which would look like a pass.
|
||||
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(
|
||||
env.dapp,
|
||||
@@ -1921,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
|
||||
// origin to still be authorized.
|
||||
await popup.check("#approve-remember");
|
||||
await popup.click("#btn-approve");
|
||||
await clickAndClose(popup, "#btn-approve");
|
||||
|
||||
outcome = await settleRequest(env.dapp, "accounts");
|
||||
} finally {
|
||||
@@ -1945,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) => {
|
||||
await startRequest(env.dapp, "sign", "personal_sign", [
|
||||
SIGN_HEX,
|
||||
@@ -2029,7 +2747,7 @@ test("personal_sign rejected returns a rejection to the page (#183)", async (env
|
||||
]);
|
||||
const popup = await waitForApprovalWindow(env.ctx);
|
||||
await visible(popup, "#view-approve-sign");
|
||||
await popup.click("#btn-reject-sign");
|
||||
await clickAndClose(popup, "#btn-reject-sign");
|
||||
|
||||
await assertUserRejection(
|
||||
env.dapp,
|
||||
@@ -2132,7 +2850,7 @@ test("eth_signTypedData_v4 rejected returns a rejection to the page (#183)", asy
|
||||
]);
|
||||
const popup = await waitForApprovalWindow(env.ctx);
|
||||
await visible(popup, "#view-approve-sign");
|
||||
await popup.click("#btn-reject-sign");
|
||||
await clickAndClose(popup, "#btn-reject-sign");
|
||||
|
||||
await assertUserRejection(
|
||||
env.dapp,
|
||||
@@ -2290,7 +3008,7 @@ test("eth_sendTransaction rejected broadcasts nothing (#183)", async (env) => {
|
||||
]);
|
||||
const popup = await waitForApprovalWindow(env.ctx);
|
||||
await visible(popup, "#view-approve-tx");
|
||||
await popup.click("#btn-reject-tx");
|
||||
await clickAndClose(popup, "#btn-reject-tx");
|
||||
|
||||
await assertUserRejection(
|
||||
env.dapp,
|
||||
@@ -2371,6 +3089,7 @@ async function main() {
|
||||
// starting state of a run is readable without hunting through tests.
|
||||
const routeOpts = {
|
||||
seedTokenTransfer: false,
|
||||
seedNativeTransfer: false,
|
||||
seedTokenBalance: false,
|
||||
ethBalanceWei: null,
|
||||
failGasEstimate: false,
|
||||
@@ -2407,6 +3126,11 @@ async function main() {
|
||||
// The recovery phrase of the wallet created in test 2, so later
|
||||
// tests can assert on the real secret rather than its shape.
|
||||
phrase: null,
|
||||
// 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
|
||||
// compared against every later state of the same screen.
|
||||
ethPendingHeight: null,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// One wording for one condition (issue #172).
|
||||
// One wording for one condition (issues #172 and #265).
|
||||
//
|
||||
// Every screen that asks for the password decrypts the vault itself, and
|
||||
// each one used to write its own sentence for the same failure: the send
|
||||
@@ -21,6 +21,15 @@
|
||||
// call site is read back to its own catch handler and the prose that
|
||||
// handler shows the user must be the canonical sentence and nothing else
|
||||
// — which fails on a novel wording, not only on a known-superseded one.
|
||||
//
|
||||
// The empty-password condition (#265) is pinned the same way and off the
|
||||
// same call sites: the private key export screen said "Password is
|
||||
// required." where the other five said "Please enter your password." Each
|
||||
// decrypt's password variable is walked back to the guard that rejects it
|
||||
// when blank, and the prose that guard shows must be the canonical
|
||||
// sentence. Anchoring on the decrypt keeps the wallet-creation screen out
|
||||
// of the set: an empty field there is a password being CHOSEN, a
|
||||
// different condition with its own wording.
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
@@ -28,6 +37,7 @@ const path = require("path");
|
||||
const SRC = path.join(__dirname, "..", "src");
|
||||
|
||||
const CANONICAL = "That password is incorrect. Please try again.";
|
||||
const CANONICAL_EMPTY = "Please enter your password.";
|
||||
|
||||
// Wordings this repo has actually shipped for the same condition. This is
|
||||
// a secondary, whole-file sweep for stragglers outside a decrypt handler;
|
||||
@@ -36,6 +46,7 @@ const CANONICAL = "That password is incorrect. Please try again.";
|
||||
const SUPERSEDED = [
|
||||
"Wrong password.",
|
||||
"That password is not correct. Please try again.",
|
||||
"Password is required.",
|
||||
];
|
||||
|
||||
function jsFilesUnder(dir) {
|
||||
@@ -149,6 +160,71 @@ function handlerMessages(file, callOffset, label) {
|
||||
.filter((v) => v.includes(" "));
|
||||
}
|
||||
|
||||
// The identifier a decrypt call passes as its password, which is what the
|
||||
// empty-field guard for that screen tests.
|
||||
function passwordArg(masked, callOffset, label) {
|
||||
const open = callOffset + "decryptWithPassword".length;
|
||||
const args = [];
|
||||
let depth = 0;
|
||||
let start = open + 1;
|
||||
for (let i = open; i < masked.length; i++) {
|
||||
const c = masked[i];
|
||||
if (c === "(" || c === "[" || c === "{") depth += 1;
|
||||
else if (c === ")" || c === "]" || c === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
args.push(masked.slice(start, i));
|
||||
break;
|
||||
}
|
||||
} else if (c === "," && depth === 1) {
|
||||
args.push(masked.slice(start, i));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
const arg = (args[1] ?? "").trim();
|
||||
if (!/^[A-Za-z_$][\w$]*$/.test(arg))
|
||||
throw new Error(`${label}: password argument is not a name: ${arg}`);
|
||||
return arg;
|
||||
}
|
||||
|
||||
// Innermost block enclosing the decrypt that also declares its password
|
||||
// variable — the handler the screen's submit button runs, which is where
|
||||
// the empty-field guard lives.
|
||||
function declaringBlock(masked, callOffset, ident, label) {
|
||||
const declared = new RegExp(`\\b(?:const|let|var)\\s+${ident}\\s*=`);
|
||||
let at = callOffset;
|
||||
for (;;) {
|
||||
const open = enclosingBlockStart(masked, at);
|
||||
if (open === -1) throw new Error(`${label}: nothing declares ${ident}`);
|
||||
const end = blockEnd(masked, open);
|
||||
if (declared.test(masked.slice(open, end))) return [open, end];
|
||||
at = open - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// The prose the empty-field guard puts in front of the user. Exactly one
|
||||
// guard per handler is required: two would mean the condition is answered
|
||||
// in more than one place and this would be pinning only one of them.
|
||||
function emptyGuardMessages(file, callOffset, label) {
|
||||
const { masked, strings } = scan(fs.readFileSync(file, "utf8"));
|
||||
const ident = passwordArg(masked, callOffset, label);
|
||||
const [from, to] = declaringBlock(masked, callOffset, ident, label);
|
||||
const guard = new RegExp(`if\\s*\\(\\s*!\\s*${ident}\\s*\\)\\s*\\{`, "g");
|
||||
const opens = [];
|
||||
let m;
|
||||
while ((m = guard.exec(masked.slice(from, to))) !== null)
|
||||
opens.push(from + m.index + m[0].length - 1);
|
||||
if (opens.length !== 1)
|
||||
throw new Error(
|
||||
`${label}: expected one empty-${ident} guard, found ${opens.length}`,
|
||||
);
|
||||
const close = blockEnd(masked, opens[0]);
|
||||
return strings
|
||||
.filter((s) => s.offset >= opens[0] && s.offset < close)
|
||||
.map((s) => s.value)
|
||||
.filter((v) => v.includes(" "));
|
||||
}
|
||||
|
||||
// The call sites are found, not listed: the file layout moves (the private
|
||||
// key export was in addressDetail.js when #172 was filed and is its own
|
||||
// view now), and a hardcoded list would quietly stop covering a screen it
|
||||
@@ -187,8 +263,9 @@ describe("password failure messages", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("the canonical message is a full sentence", () => {
|
||||
test("the canonical messages are full sentences", () => {
|
||||
expect(CANONICAL).toMatch(/^[A-Z][^]*\.$/);
|
||||
expect(CANONICAL_EMPTY).toMatch(/^[A-Z][^]*\.$/);
|
||||
});
|
||||
|
||||
// Exact equality, per call site: a message that is merely different
|
||||
@@ -203,6 +280,15 @@ describe("password failure messages", () => {
|
||||
},
|
||||
);
|
||||
|
||||
test.each(sites.map((s) => [s.label, s]))(
|
||||
"%s answers an empty password field with the canonical sentence",
|
||||
(label, site) => {
|
||||
expect(emptyGuardMessages(site.file, site.offset, label)).toEqual([
|
||||
CANONICAL_EMPTY,
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
test.each(files.map((f) => [path.relative(SRC, f), f]))(
|
||||
"%s carries no superseded wording",
|
||||
(_rel, file) => {
|
||||
|
||||
@@ -1,93 +1,128 @@
|
||||
// Extension storage stub for the Node test environment. The module resolves
|
||||
// the storage API on use, so this only has to exist before the first call.
|
||||
// Values round-trip through JSON the way structured cloning would, so a test
|
||||
// cannot pass by holding a live reference to the module's own array.
|
||||
const storageStore = {};
|
||||
global.chrome = {
|
||||
storage: {
|
||||
local: {
|
||||
get: async (key) =>
|
||||
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];
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
// The phishing blocklist is vendored at build time and shipped as digests:
|
||||
// script/vendor-blocklist writes src/shared/phishingBlocklist.json, and nothing
|
||||
// fetches anything at runtime. Two things therefore have to be proven here, and
|
||||
// the second is the one that would otherwise fail silently:
|
||||
//
|
||||
// - real domains from the vendored list are detected, and clean ones are not.
|
||||
// - a malformed artifact fails loudly. Every way of getting the artifact
|
||||
// wrong produces a blocklist that matches nothing while looking healthy,
|
||||
// which is a phishing check that answers "no" to everything.
|
||||
|
||||
const {
|
||||
isPhishingDomain,
|
||||
loadConfig,
|
||||
getBlocklistSize,
|
||||
getDeltaSize,
|
||||
hostnameVariants,
|
||||
DELTA_STORAGE_KEY,
|
||||
_reset,
|
||||
_getVendoredBlacklistSize,
|
||||
_getDeltaBlacklist,
|
||||
} = require("../src/shared/phishingDomains");
|
||||
const { HASH_HEX_CHARS, hashDomain } = require("../src/shared/domainHash");
|
||||
const vendored = require("../src/shared/phishingBlocklist.json");
|
||||
|
||||
function clearStorage() {
|
||||
for (const key of Object.keys(storageStore)) {
|
||||
delete storageStore[key];
|
||||
}
|
||||
}
|
||||
// Domains present in the vendored list at the pinned upstream commit. Upstream
|
||||
// prunes as well as adds, so re-vendoring can retire one of these and turn this
|
||||
// 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",
|
||||
];
|
||||
|
||||
// The MV3 service worker is torn down when idle and re-evaluated on the next
|
||||
// event, which wipes every module-level variable. Re-requiring the module with
|
||||
// the registry reset is exactly that: fresh in-memory state, same extension
|
||||
// storage underneath.
|
||||
function restartWorker() {
|
||||
jest.resetModules();
|
||||
return require("../src/shared/phishingDomains");
|
||||
}
|
||||
// Not on the list, and the kind of host a user actually visits.
|
||||
const CLEAN = ["etherscan.io", "example.com", "opensea.io", "sneak.berlin"];
|
||||
|
||||
// Reset delta state before each test to avoid cross-test contamination.
|
||||
// Note: vendored sets are immutable and always present.
|
||||
beforeEach(() => {
|
||||
_reset();
|
||||
clearStorage();
|
||||
});
|
||||
|
||||
describe("phishingDomains", () => {
|
||||
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", () => {
|
||||
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);
|
||||
});
|
||||
|
||||
test("every digest is lowercase hex of the declared width", () => {
|
||||
expect(vendored.hashes).toMatch(/^[0-9a-f]*$/);
|
||||
});
|
||||
|
||||
test("detects domains from the vendored list", () => {
|
||||
for (const domain of LISTED) {
|
||||
expect(isPhishingDomain(domain)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
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("hostnameVariants", () => {
|
||||
test("returns exact hostname plus parent domains", () => {
|
||||
const variants = hostnameVariants("sub.evil.com");
|
||||
expect(variants).toEqual(["sub.evil.com", "evil.com"]);
|
||||
expect(hostnameVariants("sub.evil.com")).toEqual([
|
||||
"sub.evil.com",
|
||||
"evil.com",
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns just the hostname for a bare domain", () => {
|
||||
const variants = hostnameVariants("example.com");
|
||||
expect(variants).toEqual(["example.com"]);
|
||||
expect(hostnameVariants("example.com")).toEqual(["example.com"]);
|
||||
});
|
||||
|
||||
test("handles deep subdomain chains", () => {
|
||||
const variants = hostnameVariants("a.b.c.d.com");
|
||||
expect(variants).toEqual([
|
||||
expect(hostnameVariants("a.b.c.d.com")).toEqual([
|
||||
"a.b.c.d.com",
|
||||
"b.c.d.com",
|
||||
"c.d.com",
|
||||
@@ -96,478 +131,89 @@ describe("phishingDomains", () => {
|
||||
});
|
||||
|
||||
test("lowercases hostnames", () => {
|
||||
const variants = hostnameVariants("Evil.COM");
|
||||
expect(variants).toEqual(["evil.com"]);
|
||||
expect(hostnameVariants("Evil.COM")).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);
|
||||
describe("domain hashing", () => {
|
||||
test("a digest is the declared width of lowercase hex", () => {
|
||||
const hash = hashDomain("example.com");
|
||||
expect(hash).toHaveLength(HASH_HEX_CHARS);
|
||||
expect(hash).toMatch(/^[0-9a-f]+$/);
|
||||
});
|
||||
|
||||
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("hashing is case-insensitive, so lookups are too", () => {
|
||||
expect(hashDomain("Evil.COM")).toBe(hashDomain("evil.com"));
|
||||
});
|
||||
|
||||
test("getBlocklistSize includes both vendored and delta", () => {
|
||||
const baseSize = getBlocklistSize();
|
||||
loadConfig({
|
||||
blacklist: ["delta-only-scam-xyz.com"],
|
||||
});
|
||||
expect(getBlocklistSize()).toBe(baseSize + 1);
|
||||
test("different domains get different digests", () => {
|
||||
expect(hashDomain("evil.com")).not.toBe(hashDomain("evil.org"));
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
// A blocklist that silently matches nothing is the failure this module must not
|
||||
// have, so each way of breaking the artifact is required to throw at load. The
|
||||
// generator is the only thing that writes this file, but "the generator is
|
||||
// correct" is not something the shipped extension can check at runtime — this
|
||||
// is what makes a format drift a build failure rather than a silent one.
|
||||
describe("a malformed artifact fails loudly", () => {
|
||||
const GOOD = {
|
||||
algorithm: "sha256",
|
||||
hashHexChars: HASH_HEX_CHARS,
|
||||
count: 2,
|
||||
hashes: "0".repeat(HASH_HEX_CHARS) + "1".repeat(HASH_HEX_CHARS),
|
||||
};
|
||||
|
||||
test("detects domain from vendored blacklist", () => {
|
||||
// No delta loaded — vendored still works
|
||||
expect(isPhishingDomain("hopprotocol.pro")).toBe(true);
|
||||
function loadWith(artifact) {
|
||||
let mod;
|
||||
jest.isolateModules(() => {
|
||||
jest.doMock(
|
||||
"../src/shared/phishingBlocklist.json",
|
||||
() => artifact,
|
||||
{
|
||||
virtual: false,
|
||||
},
|
||||
);
|
||||
mod = require("../src/shared/phishingDomains");
|
||||
});
|
||||
|
||||
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`);
|
||||
return mod;
|
||||
}
|
||||
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", () => {
|
||||
beforeEach(() => {
|
||||
clearStorage();
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete global.fetch;
|
||||
jest.dontMock("../src/shared/phishingBlocklist.json");
|
||||
});
|
||||
|
||||
test("a revived worker restores the persisted delta without re-fetching", async () => {
|
||||
const first = require("../src/shared/phishingDomains");
|
||||
await first.loadConfig({ blacklist: ["restart-scam-xyz.com"] });
|
||||
|
||||
const revived = restartWorker();
|
||||
// Nothing in memory yet — this is a brand new module instance.
|
||||
expect(revived.getDeltaSize()).toBe(0);
|
||||
|
||||
global.fetch = jest.fn();
|
||||
await revived.initPhishingList();
|
||||
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
expect(revived.getDeltaSize()).toBe(1);
|
||||
expect(revived.isPhishingDomain("restart-scam-xyz.com")).toBe(true);
|
||||
test("the control artifact loads", () => {
|
||||
expect(loadWith(GOOD).getBlocklistSize()).toBe(2);
|
||||
});
|
||||
|
||||
test("repeated wakes inside the cache window never re-fetch", async () => {
|
||||
const first = require("../src/shared/phishingDomains");
|
||||
await first.loadConfig({ blacklist: ["no-storm-scam-xyz.com"] });
|
||||
|
||||
global.fetch = jest.fn();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const revived = restartWorker();
|
||||
await revived.initPhishingList();
|
||||
}
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("a persisted timestamp older than the TTL causes a fetch on startup", async () => {
|
||||
const first = require("../src/shared/phishingDomains");
|
||||
await first.loadConfig({ blacklist: ["stale-scam-xyz.com"] });
|
||||
|
||||
// Age the persisted record past the 24-hour TTL.
|
||||
storageStore[first.DELTA_STORAGE_KEY].lastFetchTime =
|
||||
Date.now() - first.CACHE_TTL_MS - 1000;
|
||||
|
||||
const revived = restartWorker();
|
||||
global.fetch = jest.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ blacklist: ["refreshed-scam-xyz.com"] }),
|
||||
}));
|
||||
await revived.initPhishingList();
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(revived.isPhishingDomain("refreshed-scam-xyz.com")).toBe(true);
|
||||
expect(revived.isPhishingDomain("stale-scam-xyz.com")).toBe(false);
|
||||
});
|
||||
|
||||
test("a first start with nothing persisted fetches immediately", async () => {
|
||||
const fresh = restartWorker();
|
||||
global.fetch = jest.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ blacklist: ["first-run-scam-xyz.com"] }),
|
||||
}));
|
||||
await fresh.initPhishingList();
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(fresh.isPhishingDomain("first-run-scam-xyz.com")).toBe(true);
|
||||
});
|
||||
|
||||
test("updatePhishingList honours the persisted timestamp on its own", async () => {
|
||||
// The startup path calls updatePhishingList() directly, so it must
|
||||
// load persisted state itself rather than relying on anything else
|
||||
// having finished first.
|
||||
const first = require("../src/shared/phishingDomains");
|
||||
await first.loadConfig({ blacklist: ["alarm-tick-scam-xyz.com"] });
|
||||
|
||||
const revived = restartWorker();
|
||||
global.fetch = jest.fn();
|
||||
await revived.updatePhishingList();
|
||||
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
expect(revived.isPhishingDomain("alarm-tick-scam-xyz.com")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// The alarm period alone must set the cadence. lastFetchTime is stamped when
|
||||
// the fetch completes, so it lands one fetch latency after the alarm that
|
||||
// caused it; a freshness guard timed to the alarm period therefore vetoes
|
||||
// every scheduled tick and halves the real refresh rate. These tests measure
|
||||
// the interval between fetches that actually happened.
|
||||
describe("phishing refresh steady-state cadence", () => {
|
||||
const { PHISHING_REFRESH_PERIOD_MINUTES } = require("../src/shared/alarms");
|
||||
const PERIOD_MS = PHISHING_REFRESH_PERIOD_MINUTES * 60 * 1000;
|
||||
|
||||
let clockSpy;
|
||||
let now;
|
||||
|
||||
beforeEach(() => {
|
||||
clearStorage();
|
||||
jest.resetModules();
|
||||
now = Date.UTC(2026, 0, 1, 0, 0, 0);
|
||||
clockSpy = jest.spyOn(Date, "now").mockImplementation(() => now);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clockSpy.mockRestore();
|
||||
delete global.fetch;
|
||||
});
|
||||
|
||||
function fetchStub(latencyMs, seen) {
|
||||
return jest.fn(async () => {
|
||||
seen.push(now);
|
||||
// A network fetch takes time, and lastFetchTime is stamped after
|
||||
// it, not when the alarm fired.
|
||||
now += latencyMs;
|
||||
return { ok: true, json: async () => ({ blacklist: [] }) };
|
||||
});
|
||||
}
|
||||
|
||||
test("ten alarm ticks produce ten fetches, one per period", async () => {
|
||||
const fetchedAt = [];
|
||||
global.fetch = fetchStub(5000, fetchedAt);
|
||||
|
||||
const startup = require("../src/shared/phishingDomains");
|
||||
const T0 = now;
|
||||
await startup.initPhishingList();
|
||||
expect(fetchedAt).toEqual([T0]);
|
||||
|
||||
const TICKS = 10;
|
||||
let tickAt = T0 + PERIOD_MS;
|
||||
for (let i = 0; i < TICKS; i++) {
|
||||
now = tickAt;
|
||||
tickAt += PERIOD_MS;
|
||||
// The browser wakes a terminated worker to deliver the alarm, so
|
||||
// every tick starts from cold memory and the persisted record.
|
||||
const revived = restartWorker();
|
||||
await revived.refreshPhishingListOnSchedule();
|
||||
}
|
||||
|
||||
expect(fetchedAt).toHaveLength(TICKS + 1);
|
||||
const intervals = fetchedAt.slice(1).map((t, i) => t - fetchedAt[i]);
|
||||
expect(intervals).toEqual(new Array(TICKS).fill(PERIOD_MS));
|
||||
});
|
||||
|
||||
test("the scheduled tick fetches whatever the last fetch's latency was", async () => {
|
||||
// The alarm fires one period after the previous alarm, which is
|
||||
// `latency` short of one period since the fetch it caused completed.
|
||||
for (const latency of [200, 1000, 5000]) {
|
||||
clearStorage();
|
||||
jest.resetModules();
|
||||
storageStore[DELTA_STORAGE_KEY] = {
|
||||
blacklist: [],
|
||||
lastFetchTime: now - PERIOD_MS + latency,
|
||||
lastAttemptTime: now - PERIOD_MS,
|
||||
};
|
||||
const mod = require("../src/shared/phishingDomains");
|
||||
const fetchedAt = [];
|
||||
global.fetch = fetchStub(latency, fetchedAt);
|
||||
|
||||
await mod.refreshPhishingListOnSchedule();
|
||||
expect(fetchedAt).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
test("a worker wake inside the cache window still does not fetch", async () => {
|
||||
// The TTL is not removed, only taken off the scheduled path. Chrome
|
||||
// revives the worker every ~30 seconds and every revival runs the
|
||||
// startup path, so the TTL still has to keep that off the network.
|
||||
storageStore[DELTA_STORAGE_KEY] = {
|
||||
blacklist: [],
|
||||
lastFetchTime: now - PERIOD_MS + 5000,
|
||||
lastAttemptTime: now - PERIOD_MS,
|
||||
};
|
||||
const mod = require("../src/shared/phishingDomains");
|
||||
global.fetch = jest.fn();
|
||||
|
||||
await mod.initPhishingList();
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("phishing list timestamps that cannot be trusted", () => {
|
||||
let clockSpy;
|
||||
let now;
|
||||
|
||||
beforeEach(() => {
|
||||
clearStorage();
|
||||
jest.resetModules();
|
||||
now = Date.UTC(2026, 0, 1, 0, 0, 0);
|
||||
clockSpy = jest.spyOn(Date, "now").mockImplementation(() => now);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clockSpy.mockRestore();
|
||||
delete global.fetch;
|
||||
});
|
||||
|
||||
function okFetch() {
|
||||
return jest.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ blacklist: ["recovered-scam-xyz.com"] }),
|
||||
}));
|
||||
}
|
||||
|
||||
// jest.resetModules() clears the call record of a jest.fn, and simulating
|
||||
// a worker restart is exactly that call. Anything counted across restarts
|
||||
// has to be counted outside the mock.
|
||||
function countingFetch(counter, response) {
|
||||
return async () => {
|
||||
counter.calls++;
|
||||
return response();
|
||||
};
|
||||
}
|
||||
|
||||
test("a lastFetchTime in the future is discarded rather than trusted", async () => {
|
||||
// Clock skew or a restored profile backup writes one. Every guard
|
||||
// measures `Date.now() - stamp` and only tests the lower bound, so a
|
||||
// stamp a year ahead would suppress updates for a year, and now that
|
||||
// the value is persisted it would outlive every worker.
|
||||
storageStore[DELTA_STORAGE_KEY] = {
|
||||
blacklist: ["poisoned-scam-xyz.com"],
|
||||
lastFetchTime: now + 365 * 24 * 60 * 60 * 1000,
|
||||
lastAttemptTime: 0,
|
||||
};
|
||||
const mod = require("../src/shared/phishingDomains");
|
||||
global.fetch = okFetch();
|
||||
|
||||
await mod.initPhishingList();
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(mod.isPhishingDomain("recovered-scam-xyz.com")).toBe(true);
|
||||
// And the record it leaves behind is sane, so recovery is permanent.
|
||||
expect(
|
||||
storageStore[DELTA_STORAGE_KEY].lastFetchTime,
|
||||
).toBeLessThanOrEqual(now);
|
||||
});
|
||||
|
||||
test("a lastAttemptTime in the future does not suppress the retry", async () => {
|
||||
storageStore[DELTA_STORAGE_KEY] = {
|
||||
lastAttemptTime: now + 365 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
const mod = require("../src/shared/phishingDomains");
|
||||
global.fetch = okFetch();
|
||||
|
||||
await mod.initPhishingList();
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("an oversized delta does not re-download on every worker wake", async () => {
|
||||
// The delta and its freshness claim are both dropped, which is right,
|
||||
// but nothing then says a fetch just happened. Chrome cycles the
|
||||
// worker roughly every 30 seconds idle, so without the attempt stamp
|
||||
// this is a full blocklist download per wake, forever.
|
||||
const huge = [];
|
||||
for (let i = 0; i < 20000; i++) {
|
||||
huge.push(`oversize-scam-${i}-xyzxyzxyzxyzxyz.com`);
|
||||
}
|
||||
const counter = { calls: 0 };
|
||||
global.fetch = countingFetch(counter, () => ({
|
||||
ok: true,
|
||||
json: async () => ({ blacklist: huge }),
|
||||
}));
|
||||
|
||||
for (let wake = 0; wake < 4; wake++) {
|
||||
const revived = restartWorker();
|
||||
await revived.initPhishingList();
|
||||
now += 30 * 1000; // idle timeout, worker torn down and revived
|
||||
}
|
||||
|
||||
expect(counter.calls).toBe(1);
|
||||
expect(storageStore[DELTA_STORAGE_KEY].blacklist).toBeUndefined();
|
||||
expect(typeof storageStore[DELTA_STORAGE_KEY].lastAttemptTime).toBe(
|
||||
"number",
|
||||
test("a different digest algorithm throws", () => {
|
||||
expect(() => loadWith({ ...GOOD, algorithm: "md5" })).toThrow(
|
||||
/algorithm/,
|
||||
);
|
||||
});
|
||||
|
||||
test("a failing fetch is not retried on every worker wake either", async () => {
|
||||
const counter = { calls: 0 };
|
||||
global.fetch = countingFetch(counter, () => ({
|
||||
ok: false,
|
||||
status: 503,
|
||||
}));
|
||||
|
||||
for (let wake = 0; wake < 4; wake++) {
|
||||
const revived = restartWorker();
|
||||
await revived.initPhishingList();
|
||||
now += 30 * 1000;
|
||||
}
|
||||
|
||||
expect(counter.calls).toBe(1);
|
||||
test("a different digest width throws", () => {
|
||||
expect(() => loadWith({ ...GOOD, hashHexChars: 8 })).toThrow(
|
||||
/hex characters per entry/,
|
||||
);
|
||||
});
|
||||
|
||||
test("the retry floor expires, so a failure is not permanent", async () => {
|
||||
const {
|
||||
MIN_FETCH_ATTEMPT_INTERVAL_MS,
|
||||
} = require("../src/shared/phishingDomains");
|
||||
const counter = { calls: 0 };
|
||||
global.fetch = countingFetch(counter, () => ({
|
||||
ok: false,
|
||||
status: 503,
|
||||
}));
|
||||
|
||||
await restartWorker().initPhishingList();
|
||||
expect(counter.calls).toBe(1);
|
||||
|
||||
// Still inside the floor: no retry.
|
||||
now += MIN_FETCH_ATTEMPT_INTERVAL_MS - 1000;
|
||||
await restartWorker().initPhishingList();
|
||||
expect(counter.calls).toBe(1);
|
||||
|
||||
// Past it: the extension goes back to the network.
|
||||
now += 2000;
|
||||
await restartWorker().initPhishingList();
|
||||
expect(counter.calls).toBe(2);
|
||||
test("a count that does not match the string length throws", () => {
|
||||
expect(() => loadWith({ ...GOOD, count: 3 })).toThrow(
|
||||
/which is not the/,
|
||||
);
|
||||
});
|
||||
|
||||
test("the scheduled tick ignores the retry floor", async () => {
|
||||
// The alarm period is far above the floor, but the floor exists to
|
||||
// throttle wakes, not the schedule.
|
||||
storageStore[DELTA_STORAGE_KEY] = { lastAttemptTime: now - 1000 };
|
||||
const mod = require("../src/shared/phishingDomains");
|
||||
global.fetch = okFetch();
|
||||
test("a missing hashes string throws", () => {
|
||||
expect(() => loadWith({ ...GOOD, hashes: undefined })).toThrow(
|
||||
/no hashes string/,
|
||||
);
|
||||
});
|
||||
|
||||
await mod.refreshPhishingListOnSchedule();
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
test("an empty artifact throws rather than matching nothing", () => {
|
||||
expect(() => loadWith({ ...GOOD, count: 0, hashes: "" })).toThrow(
|
||||
/entry count/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
182
tests/popupElementIds.test.js
Normal file
182
tests/popupElementIds.test.js
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@@ -384,7 +384,7 @@ describe("the shipped token list", () => {
|
||||
"0xab5eb14c09d416f0ac63661e57edb7aecdb9befa", // Metronome Synth USD
|
||||
],
|
||||
MUSD: [
|
||||
"0xaca92e438df0b2401ff60da7e4337b687a2435da", // MetaMask USD
|
||||
"0xaca92e438df0b2401ff60da7e4337b687a2435da",
|
||||
"0xdd468a1ddc392dcdbef6db6e34e89aa338f9f186", // Mezo USD
|
||||
],
|
||||
JPYC: [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { AbiCoder, Interface, solidityPacked, getBytes } = require("ethers");
|
||||
const { AbiCoder, Interface, solidityPacked } = require("ethers");
|
||||
const uniswap = require("../src/shared/uniswap");
|
||||
|
||||
const ROUTER_ADDR = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
|
||||
|
||||
380
yarn.lock
380
yarn.lock
@@ -427,6 +427,90 @@
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz#0eaf705c941a218a43dba8e09f1df1d6cd2f1f17"
|
||||
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":
|
||||
version "8.0.2"
|
||||
resolved "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz"
|
||||
@@ -1008,6 +1092,16 @@
|
||||
dependencies:
|
||||
"@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":
|
||||
version "2.0.6"
|
||||
resolved "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz"
|
||||
@@ -1027,6 +1121,11 @@
|
||||
dependencies:
|
||||
"@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":
|
||||
version "22.7.5"
|
||||
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"
|
||||
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:
|
||||
version "4.0.0-beta.5"
|
||||
resolved "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz"
|
||||
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:
|
||||
version "4.3.2"
|
||||
resolved "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz"
|
||||
@@ -1297,6 +1416,13 @@ brace-expansion@^5.0.2:
|
||||
dependencies:
|
||||
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:
|
||||
version "3.0.3"
|
||||
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"
|
||||
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"
|
||||
resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz"
|
||||
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
|
||||
@@ -1446,6 +1572,11 @@ dedent@^1.6.0:
|
||||
resolved "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz"
|
||||
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:
|
||||
version "4.3.1"
|
||||
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"
|
||||
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:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz"
|
||||
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:
|
||||
version "1.0.2"
|
||||
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-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"
|
||||
resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz"
|
||||
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:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz"
|
||||
@@ -1617,6 +1852,13 @@ fb-watchman@^2.0.2:
|
||||
dependencies:
|
||||
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:
|
||||
version "7.1.1"
|
||||
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"
|
||||
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:
|
||||
version "3.3.1"
|
||||
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"
|
||||
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:
|
||||
version "10.5.0"
|
||||
resolved "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz"
|
||||
@@ -1694,6 +1964,11 @@ glob@^7.1.4:
|
||||
once "^1.3.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:
|
||||
version "4.2.11"
|
||||
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"
|
||||
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:
|
||||
version "3.2.0"
|
||||
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"
|
||||
integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==
|
||||
|
||||
is-glob@^4.0.3:
|
||||
is-glob@^4.0.0, is-glob@^4.0.3:
|
||||
version "4.0.3"
|
||||
resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz"
|
||||
integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
|
||||
@@ -2212,21 +2492,51 @@ jsesc@^3.0.2:
|
||||
resolved "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz"
|
||||
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:
|
||||
version "2.3.1"
|
||||
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==
|
||||
|
||||
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:
|
||||
version "2.2.3"
|
||||
resolved "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz"
|
||||
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:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz"
|
||||
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:
|
||||
version "0.8.2"
|
||||
resolved "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.8.2.tgz"
|
||||
@@ -2325,6 +2635,13 @@ locate-path@^5.0.0:
|
||||
dependencies:
|
||||
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:
|
||||
version "10.4.3"
|
||||
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"
|
||||
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:
|
||||
version "3.1.3"
|
||||
resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz"
|
||||
@@ -2456,6 +2780,18 @@ onetime@^5.1.2:
|
||||
dependencies:
|
||||
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:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz"
|
||||
@@ -2463,7 +2799,7 @@ p-limit@^2.2.0:
|
||||
dependencies:
|
||||
p-try "^2.0.0"
|
||||
|
||||
p-limit@^3.1.0:
|
||||
p-limit@^3.0.2, p-limit@^3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz"
|
||||
integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==
|
||||
@@ -2477,6 +2813,13 @@ p-locate@^4.1.0:
|
||||
dependencies:
|
||||
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:
|
||||
version "2.2.0"
|
||||
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"
|
||||
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:
|
||||
version "3.8.1"
|
||||
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"
|
||||
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:
|
||||
version "7.0.1"
|
||||
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"
|
||||
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:
|
||||
version "4.0.8"
|
||||
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"
|
||||
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:
|
||||
version "9.3.0"
|
||||
resolved "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz"
|
||||
@@ -2900,6 +3267,11 @@ which@^2.0.1:
|
||||
dependencies:
|
||||
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":
|
||||
version "7.0.0"
|
||||
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz"
|
||||
|
||||
Reference in New Issue
Block a user