Compare commits
7 Commits
3f3bfcb010
...
next
| Author | SHA1 | Date | |
|---|---|---|---|
| ff3387d8cf | |||
| 8fcdd8a053 | |||
| 7690fe6429 | |||
| a60c4a616a | |||
| 47bf38644d | |||
| 4b7a678a9b | |||
| ab1c1846a7 |
27
Dockerfile
27
Dockerfile
@@ -1,8 +1,21 @@
|
|||||||
# node:22-slim (22.x LTS), 2026-02-24
|
# node:22-slim (22.x LTS), 2026-02-24
|
||||||
FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36
|
FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36 AS base
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Marks "already inside the lint container" for script/lint, which otherwise
|
||||||
|
# shells out to docker to build the lint stage below. Nothing outside this
|
||||||
|
# image sets it.
|
||||||
|
ENV AUTISTMASK_LINT_NATIVE=1
|
||||||
|
|
||||||
|
# script/test's default 30s bound is the host figure, against a suite that
|
||||||
|
# runs in about 8s there. In here the same suite starts on a cold jest cache
|
||||||
|
# and shares the runner with the rest of the build, so 30s is marginal rather
|
||||||
|
# than a bound — it killed a healthy suite at 30.6s on a cold CI cache. 180s
|
||||||
|
# still catches a hang in three minutes and cannot be tripped by a suite that
|
||||||
|
# is merely running on contended hardware.
|
||||||
|
ENV AUTISTMASK_TEST_TIMEOUT=180
|
||||||
|
|
||||||
# script/bootstrap installs all prerequisites (make via apt here; node
|
# script/bootstrap installs all prerequisites (make via apt here; node
|
||||||
# is already in the base image, yarn comes via corepack) and runs
|
# is already in the base image, yarn comes via corepack) and runs
|
||||||
# yarn install --frozen-lockfile. Dependency manifests are copied first
|
# yarn install --frozen-lockfile. Dependency manifests are copied first
|
||||||
@@ -13,5 +26,17 @@ RUN script/bootstrap
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Lint stage — fail fast on static analysis and formatting, before the tests
|
||||||
|
# and the build. This is also the stage script/lint builds from a host, which
|
||||||
|
# is how linting stays on the pinned ESLint rather than the host's.
|
||||||
|
FROM base AS lint
|
||||||
|
RUN make lint
|
||||||
|
|
||||||
|
# Full check and build. The COPY --from is a no-op file copy whose only job is
|
||||||
|
# to make BuildKit finish the lint stage before this one starts; without it the
|
||||||
|
# stages run in parallel and a lint failure would not fail the build early.
|
||||||
|
FROM base AS check
|
||||||
|
COPY --from=lint /app/package.json /dev/null
|
||||||
|
|
||||||
RUN make check
|
RUN make check
|
||||||
RUN make build
|
RUN make build
|
||||||
|
|||||||
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
|
File: src/shared/phishingBlocklist.json
|
||||||
Source: https://github.com/AugurProject/eth-phishing-detect (config.json)
|
Source: the eth-phishing-detect community blocklist (src/config.json).
|
||||||
|
The file here is derived from it, not a copy of it: only the
|
||||||
|
blacklist is carried over, and each entry is stored as a truncated
|
||||||
|
digest rather than a domain name. script/vendor-blocklist records
|
||||||
|
the exact upstream URL, the commit it is pinned to and the hash of
|
||||||
|
the bytes that commit serves, and is what regenerates this file.
|
||||||
|
The URL previously cited here, under a different organisation,
|
||||||
|
returns 404: that repository is gone.
|
||||||
Copyright: Copyright (c) 2018 kumavis
|
Copyright: Copyright (c) 2018 kumavis
|
||||||
License: Don't Be a Dick Public License (DBAD), Version 1.2
|
License: Don't Be a Dick Public License (DBAD), Version 1.2
|
||||||
---------------------------------------------------------------------------
|
---------------------------------------------------------------------------
|
||||||
|
|||||||
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/
|
# Standard targets are thin shims; the implementations live in script/
|
||||||
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
||||||
@@ -35,6 +35,12 @@ fmt-check:
|
|||||||
check:
|
check:
|
||||||
@script/check
|
@script/check
|
||||||
|
|
||||||
|
# Assert that the competitor name appears nowhere but its documented
|
||||||
|
# exceptions. Part of check, and re-run against dist/ at the end of a build;
|
||||||
|
# separate target for re-running it alone.
|
||||||
|
check-censored:
|
||||||
|
@script/check-censored
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
@script/docker
|
@script/docker
|
||||||
|
|
||||||
@@ -45,6 +51,7 @@ build:
|
|||||||
@echo "Building extension..."
|
@echo "Building extension..."
|
||||||
@yarn run build 2>&1
|
@yarn run build 2>&1
|
||||||
@script/verify-build
|
@script/verify-build
|
||||||
|
@script/check-censored --require-dist
|
||||||
|
|
||||||
# Development-only build: enables the red DEBUG / INSECURE banner and makes
|
# Development-only build: enables the red DEBUG / INSECURE banner and makes
|
||||||
# the hardcoded test recovery phrase the output of wallet creation. Never
|
# the hardcoded test recovery phrase the output of wallet creation. Never
|
||||||
@@ -53,12 +60,19 @@ build-debug:
|
|||||||
@echo "Building extension (DEBUG)..."
|
@echo "Building extension (DEBUG)..."
|
||||||
@AUTISTMASK_DEBUG=1 yarn run build 2>&1
|
@AUTISTMASK_DEBUG=1 yarn run build 2>&1
|
||||||
@AUTISTMASK_DEBUG=1 script/verify-build
|
@AUTISTMASK_DEBUG=1 script/verify-build
|
||||||
|
@script/check-censored --require-dist
|
||||||
|
|
||||||
# Assert the compiled DEBUG state of the bundles already in dist/. Runs at
|
# Assert the compiled DEBUG state of the bundles already in dist/. Runs at
|
||||||
# the end of build and build-debug; separate target for re-running it alone.
|
# the end of build and build-debug; separate target for re-running it alone.
|
||||||
verify-build:
|
verify-build:
|
||||||
@script/verify-build
|
@script/verify-build
|
||||||
|
|
||||||
|
# Refresh src/shared/phishingBlocklist.json from its hash-pinned upstream.
|
||||||
|
# Run deliberately, land the diff: the extension does no runtime fetching, so
|
||||||
|
# the shipped list is as fresh as the last vendoring run that was released.
|
||||||
|
vendor-blocklist:
|
||||||
|
@script/vendor-blocklist
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
@rm -rf dist/
|
@rm -rf dist/
|
||||||
|
|
||||||
|
|||||||
317
README.md
317
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
|
extension contacts three user-configurable services: the configured RPC node for
|
||||||
blockchain interactions, a public CoinDesk API (no API key) for realtime price
|
blockchain interactions, a public CoinDesk API (no API key) for realtime price
|
||||||
information, and a Blockscout block-explorer API for transaction history and
|
information, and a Blockscout block-explorer API for transaction history and
|
||||||
token balances. It also fetches a community-maintained phishing domain blocklist
|
token balances. It also performs best-effort Etherscan address label lookups
|
||||||
periodically and performs best-effort Etherscan address label lookups during
|
during transaction confirmation. A community-maintained phishing domain
|
||||||
transaction confirmation.
|
blocklist is built into the extension at build time and checked locally; nothing
|
||||||
|
is fetched for it at runtime.
|
||||||
|
|
||||||
In the extension is a hardcoded list of the top ERC20 contract addresses. You
|
In the extension is a hardcoded list of the top ERC20 contract addresses. You
|
||||||
can add any ERC20 contract by contract address if you wish, but the hardcoded
|
can add any ERC20 contract by contract address if you wish, but the hardcoded
|
||||||
@@ -89,10 +90,30 @@ provide:
|
|||||||
- `script/test-e2e-firefox` — run the Firefox browser end-to-end suite (same,
|
- `script/test-e2e-firefox` — run the Firefox browser end-to-end suite (same,
|
||||||
against an image with a pinned Firefox and geckodriver, see
|
against an image with a pinned Firefox and geckodriver, see
|
||||||
[End-to-End Tests](#end-to-end-tests))
|
[End-to-End Tests](#end-to-end-tests))
|
||||||
- `script/lint` — run the linter
|
- `script/lint` — run ESLint (`eslint.config.js`) and then `prettier --check`,
|
||||||
|
failing on either. It never writes: `--fix` is not in this path, so
|
||||||
|
`make check` stays non-mutating. Linting runs in the container — the script
|
||||||
|
builds the Dockerfile's `lint` stage — because an ESLint result that depends
|
||||||
|
on whichever ESLint the host happens to have is not a result. Docker is
|
||||||
|
therefore required to lint; inside that image `AUTISTMASK_LINT_NATIVE=1` makes
|
||||||
|
the same script lint in place instead of recursing.
|
||||||
- `script/fmt` — format all files (writes)
|
- `script/fmt` — format all files (writes)
|
||||||
- `script/fmt-check` — check formatting (read-only)
|
- `script/fmt-check` — check formatting (read-only)
|
||||||
- `script/check` — run test, test-verify-build, lint, and fmt-check
|
- `script/check` — run test, test-verify-build, check-censored, lint, and
|
||||||
|
fmt-check
|
||||||
|
- `script/check-censored` — assert the competitor name RULES.md bars appears
|
||||||
|
nowhere in the working tree or under `dist/` outside its documented
|
||||||
|
exceptions: the pinned source reference in `script/vendor-blocklist`, the two
|
||||||
|
provider-shim identifiers in `src/content/inpage.js`, and one ERC-20's
|
||||||
|
on-chain name in `src/shared/tokenList.js`. Each is scoped to that path and
|
||||||
|
fails anywhere else. Part of `make check`, which inspects `dist/` when there
|
||||||
|
is one and says loudly when there is not; `make build` re-runs it with
|
||||||
|
`--require-dist`, so a build artifact is always covered
|
||||||
|
- `script/vendor-blocklist` — refresh `src/shared/phishingBlocklist.json` from
|
||||||
|
its upstream, pinned to a commit and to the sha256 of the bytes that commit
|
||||||
|
serves. Run deliberately, never as part of a build: the output is committed
|
||||||
|
and there is no runtime fetch, so the shipped list is as fresh as the last
|
||||||
|
vendoring run that was released
|
||||||
- `script/verify-build` — assert the compiled `DEBUG` state of the bundles in
|
- `script/verify-build` — assert the compiled `DEBUG` state of the bundles in
|
||||||
`dist/`: every bundle containing `src/shared/constants.js` must have `DEBUG`
|
`dist/`: every bundle containing `src/shared/constants.js` must have `DEBUG`
|
||||||
off, or on when `AUTISTMASK_DEBUG=1`. Run automatically at the end of
|
off, or on when `AUTISTMASK_DEBUG=1`. Run automatically at the end of
|
||||||
@@ -159,6 +180,23 @@ fixtures in `tests/e2e/network.js`, so the run is deterministic and fully
|
|||||||
offline; unrecognised outbound requests are reported as failures rather than
|
offline; unrecognised outbound requests are reported as failures rather than
|
||||||
silently allowed.
|
silently allowed.
|
||||||
|
|
||||||
|
It also covers the **Settings screen**, which holds the densest run of element
|
||||||
|
id lookups in the codebase and where one wrong id leaves the whole popup blank
|
||||||
|
rather than only degrading Settings: that the screen renders populated — the
|
||||||
|
About well and the wallet list are read back, so a `show()` that stopped early
|
||||||
|
is caught rather than merely a view that failed to appear — that the four Token
|
||||||
|
Spam Protection controls are real checkboxes defaulted on, and that the theme
|
||||||
|
and network selectors offer exactly the choices `src/shared/networks.js` and
|
||||||
|
`index.html` define. What the selectors persist is then driven through the UI to
|
||||||
|
`dark` and `sepolia` and reasserted after a popup reopen before being restored
|
||||||
|
the same way; neither value is its selector's first `<option>`, so neither can
|
||||||
|
be read back from the markup with no JavaScript having run. One spam filter is
|
||||||
|
likewise toggled off and back on across a reopen each way, which exercises the
|
||||||
|
change handler, `saveState()`, `loadState()` and the assignment `init()` makes.
|
||||||
|
Each group of these assertions records a coverage key and a final case demands
|
||||||
|
the exact set, so a section that silently stopped running reddens the suite
|
||||||
|
instead of quietly shrinking it.
|
||||||
|
|
||||||
It also covers the confirmation screen, for both a native ETH send and an ERC-20
|
It also covers the confirmation screen, for both a native ETH send and an ERC-20
|
||||||
send: Send disabled while the fee estimate is in flight, enabled once it lands,
|
send: Send disabled while the fee estimate is in flight, enabled once it lands,
|
||||||
the fee block quoting the expected cost and the reserve separately, the distinct
|
the fee block quoting the expected cost and the reserve separately, the distinct
|
||||||
@@ -215,16 +253,16 @@ That interception covers the MV3 background service worker as well as the popup
|
|||||||
page, which it does not by default — `script/test-e2e` sets
|
page, which it does not by default — `script/test-e2e` sets
|
||||||
`PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1` for it. Because that flag is
|
`PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1` for it. Because that flag is
|
||||||
experimental, the harness does not take it on trust. At launch it waits for the
|
experimental, the harness does not take it on trust. At launch it waits for the
|
||||||
background worker's **own** startup request — the phishing blocklist fetch that
|
background worker to exist, asks it for one throwaway `fetch()` of its own, and
|
||||||
`src/background/index.js` issues on startup, which on the suite's throwaway
|
requires that request to arrive in the route handler within 30 seconds or aborts
|
||||||
profile always happens because no previous fetch timestamp is persisted — to
|
the entire suite (`tests/e2e/harness.js`). The anchor used to be the worker's
|
||||||
arrive in the route handler, and aborts the entire suite if none does within 30
|
own startup traffic — the phishing blocklist fetch — and there is no longer any:
|
||||||
seconds (`tests/e2e/harness.js`). The check is passive on purpose: a synthetic
|
the blocklist is vendored at build time and the extension contacts nobody when
|
||||||
probe fetched from inside the worker via `worker.evaluate()` was tried first and
|
it starts. An earlier synthetic probe was rejected because evaluating in an
|
||||||
rejected, because evaluating in an extension service worker that early kills the
|
extension service worker immediately after launch killed the worker outright;
|
||||||
worker outright, destroying the thing being measured. Observing traffic the
|
waiting for the worker to be handed over first, and issuing a `fetch()` that is
|
||||||
extension already generates perturbs nothing. Losing the race fails closed — the
|
not awaited, does not. Failing the probe fails closed — the suite refuses to run
|
||||||
suite refuses to run rather than passing quietly.
|
rather than passing quietly.
|
||||||
|
|
||||||
As defence in depth, Chrome is also started with
|
As defence in depth, Chrome is also started with
|
||||||
`--host-resolver-rules=MAP * ~NOTFOUND`, so a request that ever did slip past
|
`--host-resolver-rules=MAP * ~NOTFOUND`, so a request that ever did slip past
|
||||||
@@ -234,18 +272,29 @@ being intercepted, run with `E2E_TRACE_NETWORK=1` and every routed request is
|
|||||||
printed, tagged `[sw]` or `[page]`.
|
printed, tagged `[sw]` or `[page]`.
|
||||||
|
|
||||||
**Any uncaught page error or `console.error` fails the run.** That is the point:
|
**Any uncaught page error or `console.error` fails the run.** That is the point:
|
||||||
a `ReferenceError` from a used-but-not-imported identifier is invisible to
|
this suite exists because a `ReferenceError` from a used-but-not-imported
|
||||||
`make check` (`script/lint` is only `prettier --check`) but fatal in a browser,
|
identifier shipped twice, fatal in a browser and invisible to a `make check`
|
||||||
and this suite exists because exactly that class of bug shipped twice.
|
that was `prettier --check` only. ESLint's `no-undef` now catches that exact
|
||||||
|
class before a browser is involved, so this suite is no longer the only thing
|
||||||
|
standing between it and a release — but a static rule only sees identifiers, and
|
||||||
|
the runtime errors this suite catches are broader than one rule.
|
||||||
|
|
||||||
### Firefox (`make test-e2e-firefox`)
|
### Firefox (`make test-e2e-firefox`)
|
||||||
|
|
||||||
`make test-e2e-firefox` builds `dist/firefox/` and drives the **real popup in a
|
`make test-e2e-firefox` builds `dist/firefox/` and drives the **real popup in a
|
||||||
real Firefox**, installed as an unpacked MV2 temporary add-on via geckodriver.
|
real Firefox**, installed as an unpacked MV2 temporary add-on via geckodriver.
|
||||||
It covers popup load, wallet creation through the UI, and the Add Token screen.
|
It covers popup load, wallet creation through the UI, the Add Token screen, and
|
||||||
The suite lives in `tests/e2e/firefox/` and has **no npm dependencies at all**:
|
the four dApp round trips — `eth_requestAccounts`, `personal_sign`,
|
||||||
it is a small WebDriver client built on global `fetch` and `child_process`
|
`eth_sendTransaction`, and a closed approval window rejecting with EIP-1193 4001
|
||||||
against geckodriver's HTTP API.
|
— driven through the real content script, background page and approval windows.
|
||||||
|
|
||||||
|
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
|
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
|
build baked in; what differs is the base. The Chrome image layers those on top
|
||||||
@@ -269,20 +318,39 @@ because BiDi's `browsingContext.navigate` refuses `moz-extension://` outright.
|
|||||||
**Any uncaught error from a `moz-extension://` source fails the run**, including
|
**Any uncaught error from a `moz-extension://` source fails the run**, including
|
||||||
errors from the background page, which the suite never navigates to: a `throw`
|
errors from the background page, which the suite never navigates to: a `throw`
|
||||||
at the top of `src/background/index.js` kills the background page and fails
|
at the top of `src/background/index.js` kills the background page and fails
|
||||||
step 1. Content-script errors should arrive by the same route, but this suite
|
step 1. Content scripts **are** exercised now — the dApp steps drive a page
|
||||||
does not exercise it and does not claim it — with `--network none` there is no
|
served from loopback, which survives `--network none` — but the _capture_ of a
|
||||||
`http://` page for a content script to be injected into. Errors from add-on
|
content-script error by this route is still unproven: no probe has forced a
|
||||||
install and background startup are folded into step 1 rather than discarded.
|
throw inside one and watched it fail the run, so it remains an expectation
|
||||||
Errors are read from the privileged `nsIConsoleService` in Marionette's chrome
|
rather than a demonstrated fact. Errors from add-on install and background
|
||||||
context and filtered to non-warning entries whose `sourceName` is the extension
|
startup are folded into step 1 rather than discarded.
|
||||||
origin. That mechanism is not a stylistic choice. WebDriver BiDi's
|
|
||||||
`log.entryAdded` delivers **nothing** for extension pages: on a plain `http://`
|
An **unhandled promise rejection counts as an uncaught error** on both suites,
|
||||||
page it reports uncaught errors with stack traces, and on the `moz-extension://`
|
which matters because a good deal of popup code is now `async` and called
|
||||||
popup it reports zero events, because Firefox's remote agent excludes extension
|
without an `await`. Demonstrated, not assumed: a `throw` placed past the first
|
||||||
browsing contexts from BiDi observation. Any harness built on Playwright-BiDi or
|
`await` of `approval.show()` — which nothing awaits — turns the
|
||||||
Puppeteer-BiDi would therefore see nothing and report success, which is exactly
|
`eth_requestAccounts` step red on Firefox
|
||||||
the vacuous check this repo has already shipped twice. Do not migrate this suite
|
(`uncaught extension errors during this step`, from the console-service drain)
|
||||||
to BiDi.
|
and on Chrome (`pageerror`), with the rest of the run unaffected because the
|
||||||
|
approval view had already rendered.
|
||||||
|
|
||||||
|
One error is tolerated rather than fatal, listed in `ALLOWED_ERRORS` in
|
||||||
|
`tests/e2e/firefox/run.js` with the issue that will delete it, and printed on
|
||||||
|
every occurrence so the concession stays visible in the run output. It is
|
||||||
|
Firefox reporting the site-approval popup's unawaited `sendMessage` settling
|
||||||
|
after `window.close()` unloaded the context — the same teardown ordering as
|
||||||
|
[#275](https://git.eeqj.de/sneak/AutistMask/issues/275), and unsuppressable from
|
||||||
|
the calling code, because `BaseContext.wrapPromise` reports it whether or not a
|
||||||
|
handler is attached. Errors are read from the privileged `nsIConsoleService` in
|
||||||
|
Marionette's chrome context and filtered to non-warning entries whose
|
||||||
|
`sourceName` is the extension origin. That mechanism is not a stylistic choice.
|
||||||
|
WebDriver BiDi's `log.entryAdded` delivers **nothing** for extension pages: on a
|
||||||
|
plain `http://` page it reports uncaught errors with stack traces, and on the
|
||||||
|
`moz-extension://` popup it reports zero events, because Firefox's remote agent
|
||||||
|
excludes extension browsing contexts from BiDi observation. Any harness built on
|
||||||
|
Playwright-BiDi or Puppeteer-BiDi would therefore see nothing and report
|
||||||
|
success, which is exactly the vacuous check this repo has already shipped twice.
|
||||||
|
Do not migrate this suite to BiDi.
|
||||||
|
|
||||||
Two limits are worth knowing, both real differences from the Chrome suite:
|
Two limits are worth knowing, both real differences from the Chrome suite:
|
||||||
|
|
||||||
@@ -306,17 +374,19 @@ Two limits are worth knowing, both real differences from the Chrome suite:
|
|||||||
but a step that logs heavily could evict unread errors. What poll-based costs
|
but a step that logs heavily could evict unread errors. What poll-based costs
|
||||||
is location, not coverage: an error cannot be placed within a step the way the
|
is location, not coverage: an error cannot be placed within a step the way the
|
||||||
Chrome suite's `pageerror` events place it.
|
Chrome suite's `pageerror` events place it.
|
||||||
- **Nothing is stubbed, which inverts the coverage of network-dependent code.**
|
- **Almost nothing is stubbed, which inverts the coverage of network-dependent
|
||||||
There is no fixture layer; the container runs with `--network none` instead,
|
code.** The container still runs with `--network none`, so the run is offline
|
||||||
so the run is offline and deterministic and no request can escape. The
|
and no request can escape. The one thing it can reach is the loopback fixture
|
||||||
extension swallows its own fetch failures, so the flows are unaffected — but
|
in `tests/e2e/firefox/dapp.js`, which serves the dApp page and a JSON-RPC node
|
||||||
every network call fails, so only the _failure_ branches of code that depends
|
and which the extension's `rpcUrl` is pointed at for the dApp steps; a
|
||||||
on one are ever executed. A `ReferenceError` in the success path of
|
JSON-RPC method that fixture does not model fails the run rather than
|
||||||
`renderTransactions`, or of price or balance rendering, passes this suite
|
answering `null`. Everything else — Blockscout, the price feed, the phishing
|
||||||
green. The offline run is also weaker than the Chrome suite's interception: it
|
blocklist — has no fixture and simply fails, and the extension swallows its
|
||||||
proves nothing got out, but it cannot report which requests were attempted.
|
own fetch failures, so only the _failure_ branches of that code are ever
|
||||||
Closing that gap needs a fixture layer, deliberately out of scope for this
|
executed. A `ReferenceError` in the success path of `renderTransactions`, or
|
||||||
harness.
|
of price rendering, passes this suite green. The offline run is also weaker
|
||||||
|
than the Chrome suite's interception for those calls: it proves nothing got
|
||||||
|
out, but it cannot report which requests were attempted.
|
||||||
|
|
||||||
Neither `make test-e2e` nor `make test-e2e-firefox` is part of `make check` or
|
Neither `make test-e2e` nor `make test-e2e-firefox` is part of `make check` or
|
||||||
`make test`. `REPO_POLICIES.md` caps `make test` at 20 seconds and a browser
|
`make test`. `REPO_POLICIES.md` caps `make test` at 20 seconds and a browser
|
||||||
@@ -362,6 +432,24 @@ 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
|
once that layer is cached. `e2e-firefox` about 1m05s cold, and it caches its
|
||||||
Firefox and geckodriver downloads the same way.
|
Firefox and geckodriver downloads the same way.
|
||||||
|
|
||||||
|
### Element id guard (part of `make check`)
|
||||||
|
|
||||||
|
`tests/popupElementIds.test.js` asserts statically that every element id the
|
||||||
|
popup looks up — `$("...")`, `document.getElementById("...")`,
|
||||||
|
`showError()`/`hideError()`, and the `view-<name>` a literal `showView("...")`
|
||||||
|
resolves to — exists in `src/popup/index.html`, and that `index.html` defines no
|
||||||
|
id twice. A wrong id is valid JavaScript naming a defined function, so neither
|
||||||
|
jest (node environment, no DOM) nor a linter objects to it; at runtime `$()`
|
||||||
|
returns `null` and the next property access throws, which inside a view's
|
||||||
|
`init()` aborts the rest of `src/popup/index.js` `init()` and leaves the popup
|
||||||
|
blank.
|
||||||
|
|
||||||
|
It runs with no browser, so unlike the e2e suites it fits inside `make check`,
|
||||||
|
and it covers every view rather than the ones some test happens to open. It only
|
||||||
|
sees literal arguments: a call like `$(containerId)` is invisible to it, and a
|
||||||
|
lookup naming the wrong existing element is valid by construction. Both of those
|
||||||
|
are the browser suites' job.
|
||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
|
|
||||||
Common popular EVM wallets have become bloated with swap UIs, portfolio
|
Common popular EVM wallets have become bloated with swap UIs, portfolio
|
||||||
@@ -423,60 +511,46 @@ on the next event. Two consequences shape every recurring job in the background:
|
|||||||
|
|
||||||
- `setInterval` and `setTimeout` are useless. They are destroyed with the
|
- `setInterval` and `setTimeout` are useless. They are destroyed with the
|
||||||
worker, so a job scheduled that way runs until the first idle period and never
|
worker, so a job scheduled that way runs until the first idle period and never
|
||||||
again. Both recurring jobs — the 60-second balance refresh and the 24-hour
|
again. The one recurring job — the 60-second balance refresh — is scheduled
|
||||||
phishing blocklist refresh — are scheduled through the extension alarms API
|
through the extension alarms API (`src/shared/alarms.js`) instead. The browser
|
||||||
(`src/shared/alarms.js`) instead. The browser holds the schedule and wakes the
|
holds the schedule and wakes the worker to deliver it. Alarm periods are
|
||||||
worker to deliver it. Alarm periods are clamped to a one-minute minimum, so
|
clamped to a one-minute minimum, so the balance refresh is expressed as
|
||||||
the balance refresh is expressed as exactly one minute and nothing is silently
|
exactly one minute and nothing is silently slowed down.
|
||||||
slowed down.
|
|
||||||
- Module-level variables do not survive either. Anything that must be remembered
|
- Module-level variables do not survive either. Anything that must be remembered
|
||||||
across a restart goes in extension storage, including the timestamp of the
|
across a restart goes in extension storage. `localStorage` does not exist in a
|
||||||
last phishing list fetch: without it a revived worker would either re-fetch on
|
service worker at all — the one remaining user of it, `src/shared/ens.js`,
|
||||||
every wake or, with a naive in-memory guard, never notice that an update is
|
runs only in the popup and is marked as such.
|
||||||
due. `localStorage` does not exist in a service worker at all — the one
|
|
||||||
remaining user of it, `src/shared/ens.js`, runs only in the popup and is
|
|
||||||
marked as such.
|
|
||||||
|
|
||||||
Both jobs also carry a freshness guard, and a guard must never be timed to the
|
The job also carries a freshness guard, and a guard must never be timed to the
|
||||||
alarm period it gates. Each guard is measured from the moment the last run
|
alarm period it gates. The guard is measured from the moment the last run
|
||||||
finished, which is one run-duration after the alarm that started it, so a guard
|
finished, which is one run-duration after the alarm that started it, so a guard
|
||||||
of exactly one period vetoes the very next tick and the real cadence becomes two
|
of exactly one period vetoes the very next tick and the real cadence becomes two
|
||||||
periods. The two jobs solve this differently, because their guards exist for
|
periods. The balance refresh guard exists to skip work an open popup has already
|
||||||
different reasons:
|
done — the popup refreshes every 10 seconds and stamps the same field — and that
|
||||||
|
has to keep applying on the scheduled tick, so the guard is shortened to half
|
||||||
- The phishing refresh has a 24-hour cache TTL whose job is to keep the worker
|
the alarm period rather than bypassed: comfortably above the popup's 10 seconds,
|
||||||
off the network on the wakes between scheduled refreshes — Chrome revives the
|
so an open popup still suppresses the background job, and comfortably below the
|
||||||
worker every ~30 seconds while the browser is busy, and every revival runs the
|
|
||||||
startup path. The scheduled alarm tick is not one of those wakes, so it
|
|
||||||
bypasses the TTL and fetches unconditionally. Shortening the TTL instead would
|
|
||||||
not work: the startup path re-checks it on every wake, so a shorter TTL simply
|
|
||||||
becomes the real refresh rate.
|
|
||||||
- The balance refresh guard exists to skip work an open popup has already done —
|
|
||||||
the popup refreshes every 10 seconds and stamps the same field. That has to
|
|
||||||
keep applying on the scheduled tick, so the guard is shortened to half the
|
|
||||||
alarm period instead of bypassed: comfortably above the popup's 10 seconds, so
|
|
||||||
an open popup still suppresses the background job, and comfortably below the
|
|
||||||
60-second period, so the schedule always wins.
|
60-second period, so the schedule always wins.
|
||||||
|
|
||||||
Two timestamps are persisted for the phishing list, not one. `lastFetchTime`
|
Retiring a job means clearing its alarm, not just deleting its handler. The
|
||||||
records a fetch that produced a usable delta and drives the TTL.
|
browser keeps an alarm until something removes it, so an install that once ran
|
||||||
`lastAttemptTime` records that the network was contacted at all, and is written
|
the version which created it goes on being woken on that schedule forever. Names
|
||||||
even when the result is unusable — a failed request, or a delta over the 256 KiB
|
that are no longer handled are listed in `OBSOLETE_ALARMS` and cleared on every
|
||||||
cap. Without it those cases leave no freshness mark and the worker re-downloads
|
start; the 24-hour phishing blocklist refresh is there, retired when the runtime
|
||||||
the full blocklist on every wake, indefinitely; with it, unscheduled retries are
|
fetch was removed.
|
||||||
floored at one hour. Both are discarded on load if they are in the future, since
|
|
||||||
a stamp from a skewed clock or a restored backup would otherwise suppress
|
|
||||||
updates until that time arrives, permanently and with no way out.
|
|
||||||
|
|
||||||
The startup path (`ensureRecurringAlarms()` plus the phishing list init) runs on
|
The startup path (`ensureRecurringAlarms()`) runs on `onInstalled`, on
|
||||||
`onInstalled`, on `onStartup`, and at the top level of the worker, so every way
|
`onStartup`, and at the top level of the worker, so every way the background
|
||||||
the background context can start re-establishes the schedule. On a fresh install
|
context can start re-establishes the schedule. On a fresh install more than one
|
||||||
more than one of those fires, so they share a single in-flight run rather than
|
of those fires, so they share a single in-flight run rather than racing. It is
|
||||||
racing. It is idempotent: an alarm that already exists with the period the code
|
idempotent: an alarm that already exists with the period the code asks for is
|
||||||
asks for is left alone, because re-creating one restarts its schedule and a busy
|
left alone, because re-creating one restarts its schedule and a busy extension
|
||||||
extension would push the next fire out indefinitely. An alarm carrying a
|
would push the next fire out indefinitely. An alarm carrying a different period
|
||||||
different period — one created by an earlier version — is re-created once, or a
|
— one created by an earlier version — is re-created once, or a period changed in
|
||||||
period changed in a new release would never reach an existing install.
|
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
|
Firefox uses Manifest V2 with a persistent background page, where timers would
|
||||||
survive. Both browsers are built from one bundle and both take the alarm path,
|
survive. Both browsers are built from one bundle and both take the alarm path,
|
||||||
@@ -1339,17 +1413,6 @@ What the extension does NOT do:
|
|||||||
In addition to the three user-configurable services above (RPC endpoint,
|
In addition to the three user-configurable services above (RPC endpoint,
|
||||||
CoinDesk price API, and Blockscout API), AutistMask also contacts:
|
CoinDesk price API, and Blockscout API), AutistMask also contacts:
|
||||||
|
|
||||||
- **Phishing domain blocklist**: A community-maintained phishing domain
|
|
||||||
blocklist is vendored into the extension at build time. At runtime, the
|
|
||||||
extension fetches the live list once every 24 hours to detect newly added
|
|
||||||
domains, plus once on a start where the list is more than 24 hours old. Only
|
|
||||||
the delta (domains not already in the vendored list) is kept in memory,
|
|
||||||
keeping runtime memory usage small. The delta and the timestamp of the fetch
|
|
||||||
that produced it are persisted to extension storage if the record is under 256
|
|
||||||
KiB; an oversized delta is dropped along with its timestamp, so a later start
|
|
||||||
fetches again rather than claiming freshness for data it no longer holds. A
|
|
||||||
fetch that fails, or one whose delta was too large to store, is not retried
|
|
||||||
more than once an hour outside the 24-hour schedule.
|
|
||||||
- **Etherscan address labels**: When confirming a transaction, the extension
|
- **Etherscan address labels**: When confirming a transaction, the extension
|
||||||
performs a best-effort lookup of the recipient address on Etherscan to check
|
performs a best-effort lookup of the recipient address on Etherscan to check
|
||||||
for phishing/scam labels. This is a direct page fetch with no API key; the
|
for phishing/scam labels. This is a direct page fetch with no API key; the
|
||||||
@@ -1620,17 +1683,25 @@ indexes it as a real token transfer.
|
|||||||
|
|
||||||
AutistMask protects users from known phishing sites when they connect their
|
AutistMask protects users from known phishing sites when they connect their
|
||||||
wallet or approve transactions/signatures. A community-maintained domain
|
wallet or approve transactions/signatures. A community-maintained domain
|
||||||
blocklist is vendored into the extension at build time, providing immediate
|
blocklist is vendored into the extension at build time and checked entirely
|
||||||
protection without any network requests. At runtime, the extension fetches the
|
locally: no network request is made for it, ever, so nobody learns which sites
|
||||||
live list once every 24 hours and keeps only the delta (newly added domains not
|
the user connects to and no third party decides what this wallet warns about.
|
||||||
in the vendored list) in memory. This architecture keeps runtime memory usage
|
|
||||||
small while ensuring fresh coverage of new phishing domains.
|
|
||||||
|
|
||||||
The 24-hour cadence is an alarm, not a timer; the alarm tick fetches
|
The trade is freshness. The shipped list is exactly as current as the last
|
||||||
unconditionally rather than re-checking the 24-hour cache TTL that gates the
|
vendoring run that was released, so a domain added upstream reaches users in the
|
||||||
startup path; and the fetch timestamps live in extension storage rather than in
|
next release rather than within a day. Refreshing it is `make vendor-blocklist`,
|
||||||
module variables — see [Background scheduling](#background-scheduling) for why
|
which fetches a hash-pinned upstream commit, verifies the sha256 of the bytes it
|
||||||
all three are required.
|
was served, and rewrites `src/shared/phishingBlocklist.json`; the diff is
|
||||||
|
committed and ships with the next version.
|
||||||
|
|
||||||
|
The artifact holds digests, not domain names: sha256 truncated to 64 bits, one
|
||||||
|
entry per 16 hex characters, concatenated in sorted order into a single string
|
||||||
|
(`src/shared/domainHash.js`). A lookup hashes the hostname and its parent
|
||||||
|
domains and binary-searches that string, so nothing is built at module load —
|
||||||
|
which matters on MV3, where the worker re-evaluates the module on every wake —
|
||||||
|
and the file is 1.7 MB rather than 8.7 MB. Storing digests is also what makes a
|
||||||
|
list assembled elsewhere shippable here at all: the extension carries no
|
||||||
|
plaintext list of anyone's domain names.
|
||||||
|
|
||||||
When a dApp on a blocklisted domain requests a wallet connection, transaction
|
When a dApp on a blocklisted domain requests a wallet connection, transaction
|
||||||
approval, or signature, the approval popup displays a prominent red warning
|
approval, or signature, the approval popup displays a prominent red warning
|
||||||
@@ -1727,17 +1798,23 @@ covered by the GPL-3.0 license above. These files, their copyright holders, and
|
|||||||
their licenses are:
|
their licenses are:
|
||||||
|
|
||||||
| File | Source | Copyright | License |
|
| File | Source | Copyright | License |
|
||||||
| ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------- | -------------------------------------------------------------- |
|
| ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | --------------------------------- | -------------------------------------------------------------- |
|
||||||
| `src/shared/phishingBlocklist.json` | `eth-phishing-detect` community-maintained phishing domain blocklist, vendored from its `src/config.json` | Copyright (c) 2018 kumavis | [DBAD (Don't Be a Dick)](https://github.com/philsturgeon/dbad) |
|
| `src/shared/phishingBlocklist.json` | `eth-phishing-detect` community-maintained phishing domain blocklist, derived from its `src/config.json` | Copyright (c) 2018 kumavis | [DBAD (Don't Be a Dick)](https://github.com/philsturgeon/dbad) |
|
||||||
| `src/shared/scamlist.js` (address data from MyEtherWallet) | [ethereum-lists](https://github.com/MyEtherWallet/ethereum-lists) `addresses-darklist.json` | Copyright (c) 2020 MyEtherWallet | MIT |
|
| `src/shared/scamlist.js` (address data from MyEtherWallet) | [ethereum-lists](https://github.com/MyEtherWallet/ethereum-lists) `addresses-darklist.json` | Copyright (c) 2020 MyEtherWallet | MIT |
|
||||||
| `src/shared/scamlist.js` (address data from EtherScamDB) | [EtherScamDB](https://github.com/MrLuit/EtherScamDB) `scams.yaml` | Copyright (c) 2018 Luit Hollander | MIT |
|
| `src/shared/scamlist.js` (address data from EtherScamDB) | [EtherScamDB](https://github.com/MrLuit/EtherScamDB) `scams.yaml` | Copyright (c) 2018 Luit Hollander | MIT |
|
||||||
|
|
||||||
The full license texts for these third-party files are included in the
|
The full license texts for these third-party files are included in the
|
||||||
[LICENSE](LICENSE) file. The `eth-phishing-detect` row carries no repository
|
[LICENSE](LICENSE) file. The `eth-phishing-detect` row carries no repository
|
||||||
link because the upstream is hosted under a competitor's organization name,
|
link because the upstream is hosted under a competitor's organization name,
|
||||||
which project policy keeps out of code and documentation; the vendored copy and
|
which project policy keeps out of code and documentation.
|
||||||
the runtime refresh both come from that upstream, whose URL is the
|
`script/vendor-blocklist` is the single definition and the only file that spells
|
||||||
`BLOCKLIST_URL` constant in `src/shared/phishingDomains.js`.
|
the name in prose: it is build-time tooling, never shipped, and it records the
|
||||||
|
exact URL, the commit it is pinned to and the sha256 of the bytes that commit
|
||||||
|
serves, because a source reference nobody can verify is not a source reference.
|
||||||
|
`script/check-censored` reads the name back out of that one file and fails the
|
||||||
|
build wherever else it appears, save for three shipped-code literals it cannot
|
||||||
|
avoid — each permitted only at the one path that carries it, and listed in that
|
||||||
|
script's header.
|
||||||
|
|
||||||
## Author
|
## Author
|
||||||
|
|
||||||
|
|||||||
137
TODO.md
137
TODO.md
@@ -32,20 +32,69 @@ The backlog lives on the
|
|||||||
[Gitea tracker](https://git.eeqj.de/sneak/AutistMask/issues), which is
|
[Gitea tracker](https://git.eeqj.de/sneak/AutistMask/issues), which is
|
||||||
authoritative; this file does not duplicate it. Full policy file set present.
|
authoritative; this file does not duplicate it. Full policy file set present.
|
||||||
Real-browser end-to-end suites (`make test-e2e` for Chrome,
|
Real-browser end-to-end suites (`make test-e2e` for Chrome,
|
||||||
`make test-e2e-firefox` for Firefox) now sit alongside `make check`, which
|
`make test-e2e-firefox` for Firefox) sit alongside `make check`, which now does
|
||||||
cannot see a runtime `ReferenceError` in a popup view, and
|
static analysis as well as formatting, and `.gitea/workflows/e2e.yml` runs both
|
||||||
`.gitea/workflows/e2e.yml` runs both of them on every push.
|
of them on every push.
|
||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
Land [#152](https://git.eeqj.de/sneak/AutistMask/issues/152): add ESLint to
|
Pre-1.0 security review of the extension (key handling, DEBUG mode policy, RPC
|
||||||
`script/lint`. `make check` is `prettier --check` only today and cannot catch
|
input validation) before any 1.0rc tag. Individual filed issues are parts of it,
|
||||||
undefined identifiers, which is how
|
but the review is broader than any of them.
|
||||||
[#150](https://git.eeqj.de/sneak/AutistMask/issues/150) and
|
|
||||||
[#151](https://git.eeqj.de/sneak/AutistMask/issues/151) shipped.
|
|
||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-08-17: The Settings screen is driven in a browser, and every element id
|
||||||
|
the popup looks up is checked statically. Nothing exercised Settings in the
|
||||||
|
e2e suite, and jest runs with no DOM, so the densest run of `$("...")` lookups
|
||||||
|
in the codebase was unverified at runtime. Seven new cases in
|
||||||
|
`tests/e2e/run.js` reach Settings, assert the About well and the wallet list
|
||||||
|
were actually written, assert the four Token Spam Protection checkboxes are
|
||||||
|
real checkboxes defaulted on, and assert the theme and network selectors offer
|
||||||
|
the choices `src/shared/networks.js` and `index.html` define. The selectors
|
||||||
|
are then driven to `dark` and `sepolia` — neither is the first `<option>`, so
|
||||||
|
neither can be read back from the markup with no JavaScript involved — and
|
||||||
|
reasserted after a popup reopen before being restored the same way, and one
|
||||||
|
spam filter is toggled off and back on across a reopen each way. Those round
|
||||||
|
trips run the change handler, `saveState()`, `loadState()` and the
|
||||||
|
`init()`/`show()` assignments rather than just looking at the screen. `show()`
|
||||||
|
no longer guards its `settings-network` lookup with `if (networkSelect)`: a
|
||||||
|
missing element must fail loudly, which is the whole failure mode this unit
|
||||||
|
exists to catch. Each group records a coverage key and a final case demands
|
||||||
|
the exact set, so a shortened or skipped section reddens the run instead of
|
||||||
|
shrinking it. `tests/popupElementIds.test.js` is the general half and runs in
|
||||||
|
`make check` with no browser: every literal id reached through `$()`,
|
||||||
|
`document.getElementById()`, `showError()`/`hideError()` and `showView()` must
|
||||||
|
exist in `src/popup/index.html`, which no id in `index.html` may define twice.
|
||||||
|
Demonstrated on four deliberate breaks — a typo'd id (both halves red), a
|
||||||
|
handler bound to the wrong but existing element (only the functional e2e case
|
||||||
|
red), a typo in a view no browser suite opens (only the static guard red), and
|
||||||
|
the deletion of both persisted-value assignments in `settings.js` (only the
|
||||||
|
selector round-trip case red)
|
||||||
|
([#229](https://git.eeqj.de/sneak/AutistMask/issues/229)).
|
||||||
|
- 2026-08-17: The phishing blocklist is vendored at build time and censored, and
|
||||||
|
the runtime fetch is gone
|
||||||
|
([#219](https://git.eeqj.de/sneak/AutistMask/issues/219)).
|
||||||
|
`script/vendor-blocklist` fetches upstream at a pinned commit, verifies the
|
||||||
|
sha256 of the bytes it was served, and writes
|
||||||
|
`src/shared/phishingBlocklist.json` as truncated sha256 digests rather than
|
||||||
|
domain names — which is what removes the competitor's name from a list that
|
||||||
|
carried it 6,475 times, without dropping a single one of those domains.
|
||||||
|
`script/check-censored` runs in `make check` and again against `dist/` at the
|
||||||
|
end of every build, each permitted occurrence scoped to the one path allowed
|
||||||
|
to carry it; the name now appears only in the vendoring script, which defines
|
||||||
|
it once, in the provider-shim identifiers in `src/content/inpage.js`, and in
|
||||||
|
one ERC-20's on-chain name in `src/shared/tokenList.js`. Removing the fetch
|
||||||
|
retired the delta, the persistence and the 24-hour alarm from
|
||||||
|
[#158](https://git.eeqj.de/sneak/AutistMask/issues/158), and retired alarms
|
||||||
|
are now cleared rather than left running on existing installs. Two
|
||||||
|
consequences, both deliberate: the list no longer self-updates, so it is as
|
||||||
|
fresh as the last vendoring run that was released; and re-vendoring from
|
||||||
|
current upstream took it from 231,357 stale entries to 105,721 current ones,
|
||||||
|
because upstream prunes and the vendored snapshot never did. `dist/` fell from
|
||||||
|
18.9 MB to 8.9 MB. The e2e suite now drives the warning end to end from a real
|
||||||
|
blocklisted origin, and its service-worker interception canary has a new
|
||||||
|
anchor, because the startup fetch it used to watch for no longer exists.
|
||||||
- 2026-08-17: One wording for an empty password field on every screen that asks
|
- 2026-08-17: One wording for an empty password field on every screen that asks
|
||||||
for one. The private key export screen said "Password is required." where the
|
for one. The private key export screen said "Password is required." where the
|
||||||
other five say "Please enter your password.", the same one-condition-two-
|
other five say "Please enter your password.", the same one-condition-two-
|
||||||
@@ -59,6 +108,25 @@ undefined identifiers, which is how
|
|||||||
screen's container holds at 20px with the following section at the same offset
|
screen's container holds at 20px with the following section at the same offset
|
||||||
for the old string, the new string and the empty reserved state
|
for the old string, the new string and the empty reserved state
|
||||||
([#265](https://git.eeqj.de/sneak/AutistMask/issues/265)).
|
([#265](https://git.eeqj.de/sneak/AutistMask/issues/265)).
|
||||||
|
- 2026-08-17: One shared extension-API module,
|
||||||
|
[`src/shared/browserApi.js`](src/shared/browserApi.js), is the only place in
|
||||||
|
the tree that names `browser` or `chrome`. Every call site returns a promise;
|
||||||
|
`runtime.lastError` is gone. The same commit gives the Firefox suite the four
|
||||||
|
dApp round trips — `eth_requestAccounts`, `personal_sign`,
|
||||||
|
`eth_sendTransaction` and a closed approval window rejecting with EIP-1193
|
||||||
|
4001 — against a page and a JSON-RPC node served from loopback, which survives
|
||||||
|
`--network none`. **The premise of
|
||||||
|
[#153](https://git.eeqj.de/sneak/AutistMask/issues/153) does not survive that
|
||||||
|
harness**: Firefox's `browser.*` honours a trailing Chrome-style callback and
|
||||||
|
populates `runtime.lastError`, both measured directly on Firefox 153.0.3, and
|
||||||
|
all four flows pass against the unconverted code. What landed is a uniformity
|
||||||
|
and coverage change, not a repair of a broken target. `storageGet()` and
|
||||||
|
`storageSet()` **reject** where `storage.local` is absent rather than
|
||||||
|
resolving `{}` and a no-op write — they carry the wallet, and defaulting would
|
||||||
|
read an existing wallet back as none. The one caller that genuinely degraded,
|
||||||
|
[`src/shared/phishingDomains.js`](src/shared/phishingDomains.js), took
|
||||||
|
`storageLocal()` directly and kept its own null check; it stores nothing at
|
||||||
|
all as of [#219](https://git.eeqj.de/sneak/AutistMask/issues/219) above.
|
||||||
- 2026-08-17: An address total no longer reports `$0.00` for holdings it cannot
|
- 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
|
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
|
printed as the total and an address holding nothing but unpriced ERC-20s was
|
||||||
@@ -125,6 +193,23 @@ undefined identifiers, which is how
|
|||||||
on the real clipboard, read back after a sentinel write. Each of the four was
|
on the real clipboard, read back after a sentinel write. Each of the four was
|
||||||
demonstrated failing against a deliberately broken build
|
demonstrated failing against a deliberately broken build
|
||||||
([#188](https://git.eeqj.de/sneak/AutistMask/issues/188)).
|
([#188](https://git.eeqj.de/sneak/AutistMask/issues/188)).
|
||||||
|
- 2026-08-14: `make check` does static analysis. `script/lint` ran
|
||||||
|
`prettier --check .`, byte-identical to `script/fmt-check`, so a wallet with
|
||||||
|
two shipped used-but-not-imported crashes behind it was green. ESLint is now
|
||||||
|
pinned in `package.json` with `@eslint/js` recommended as the base, flat
|
||||||
|
config in `eslint.config.js`, `no-undef` and `no-unused-vars` error-level, and
|
||||||
|
globals declared per tree — browser for the popup and content scripts, service
|
||||||
|
worker for `src/background/` and `src/shared/`, jest for `tests/`, node for
|
||||||
|
`build.js`. It found 41 unused bindings and 53 undefined identifiers; all are
|
||||||
|
fixed, and dropping a call to an unimported `foo()` into any `src/` file fails
|
||||||
|
`make lint`. Linting is also containerized now: `script/lint` builds the
|
||||||
|
Dockerfile's new `lint` stage, so the ESLint that decides whether this repo is
|
||||||
|
green is the pinned one and not the host's. The lint stage roughly doubles the
|
||||||
|
image build, so `script/test`'s hard timeout is now a bound on a hung suite
|
||||||
|
rather than a wall-clock budget: 30s on the host, where the suite runs in
|
||||||
|
about 8s, and `AUTISTMASK_TEST_TIMEOUT` raises it inside the image, where a
|
||||||
|
cold build pays install and contention costs the policy budget never described
|
||||||
|
([#152](https://git.eeqj.de/sneak/AutistMask/issues/152)).
|
||||||
- 2026-08-14: CI runs the browser end-to-end suites. `.gitea/workflows/e2e.yml`
|
- 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
|
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
|
push, separate from `check`, so `make check` and its 20-second `make test` cap
|
||||||
@@ -143,6 +228,39 @@ undefined identifiers, which is how
|
|||||||
under load, filed as [#287](https://git.eeqj.de/sneak/AutistMask/issues/287)
|
under load, filed as [#287](https://git.eeqj.de/sneak/AutistMask/issues/287)
|
||||||
rather than papered over
|
rather than papered over
|
||||||
([#259](https://git.eeqj.de/sneak/AutistMask/issues/259)).
|
([#259](https://git.eeqj.de/sneak/AutistMask/issues/259)).
|
||||||
|
- 2026-08-14: A background message handler that throws now rejects the page
|
||||||
|
instead of hanging it. `handleRpc(...).then(sendResponse)` had no `.catch()`,
|
||||||
|
and `sendResponse` is the only thing that settles the dApp's
|
||||||
|
`window.ethereum.request()` promise — so any throw inside `handleRpc` left
|
||||||
|
that promise pending forever, with no error and no timeout, indistinguishable
|
||||||
|
from a slow wallet. It now answers `{ code: -32603, message }` (the JSON-RPC
|
||||||
|
internal error EIP-1474 defines and EIP-1193 defers to; no EIP-1193 4xxx code
|
||||||
|
describes "the wallet broke" and none was invented) and logs the method and
|
||||||
|
the throw to the background console rather than swallowing them. The two async
|
||||||
|
IIFEs behind `AUTISTMASK_TX_RESPONSE` and `AUTISTMASK_SIGN_RESPONSE` were the
|
||||||
|
same shape one level down — every statement inside a `try`, but a throw out of
|
||||||
|
a `catch` block escaping unhandled — and each got a last-resort `.catch()`
|
||||||
|
settling the approval through `settleApproval()` and answering the popup. The
|
||||||
|
transaction one tracks which phase it escaped from and reports that, so an
|
||||||
|
escape before `broadcastTransaction()` says the request is gone rather than
|
||||||
|
that it may still have reached the network. Every other handler on the path is
|
||||||
|
synchronous. All four are driven by real failures — a rejecting storage read,
|
||||||
|
and a failure classifier that throws while classifying a genuine verification
|
||||||
|
or broadcast failure — and were demonstrated failing first, the RPC one with
|
||||||
|
`sendResponse` at zero calls
|
||||||
|
([#280](https://git.eeqj.de/sneak/AutistMask/issues/280)).
|
||||||
|
- 2026-08-14: Approving a site connection is no longer a race against the popup
|
||||||
|
closing. The decision now rides the approval port the popup already holds,
|
||||||
|
which is the same channel the close disconnects, so it is delivered ahead of
|
||||||
|
that disconnect however fast the teardown is; `windows.onRemoved` no longer
|
||||||
|
decides a site approval whose port is connected, since that event is ordered
|
||||||
|
against nothing either. Rejecting and closing without deciding both still
|
||||||
|
report a rejection, and the popup delays its own close by nothing. The e2e
|
||||||
|
harness's deferred-`window.close()` accommodation is gone with it, so the two
|
||||||
|
site-prompt tests now drive the shipped decide-then-close in a real Chromium;
|
||||||
|
against the unfixed code the approval came back to the page as
|
||||||
|
`{"settled":"rejected","code":4001}`
|
||||||
|
([#275](https://git.eeqj.de/sneak/AutistMask/issues/275)).
|
||||||
- 2026-08-12: EIP-1193 error codes now reach the page. `src/content/inpage.js`
|
- 2026-08-12: EIP-1193 error codes now reach the page. `src/content/inpage.js`
|
||||||
rebuilt every failure as `new Error(error.message)`, so the code the
|
rebuilt every failure as `new Error(error.message)`, so the code the
|
||||||
background produced and the content script relayed intact was dropped in the
|
background produced and the content script relayed intact was dropped in the
|
||||||
@@ -463,8 +581,5 @@ undefined identifiers, which is how
|
|||||||
Only work that has no issue of its own belongs here; everything else is on the
|
Only work that has no issue of its own belongs here; everything else is on the
|
||||||
tracker.
|
tracker.
|
||||||
|
|
||||||
- Pre-1.0 security review of the extension (key handling, DEBUG mode policy, RPC
|
|
||||||
input validation) before any 1.0rc tag. Individual filed issues are parts of
|
|
||||||
it, but the review is broader than any of them.
|
|
||||||
- Cut 1.0.0 once the milestone is empty, then continue tagging as milestones
|
- Cut 1.0.0 once the milestone is empty, then continue tagging as milestones
|
||||||
land.
|
land.
|
||||||
|
|||||||
4
build.js
4
build.js
@@ -63,7 +63,7 @@ function getBuildInfo() {
|
|||||||
commitHash = execSync("git rev-parse --short HEAD", {
|
commitHash = execSync("git rev-parse --short HEAD", {
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
}).trim();
|
}).trim();
|
||||||
} catch (_) {
|
} catch {
|
||||||
// not a git repo or git not available
|
// not a git repo or git not available
|
||||||
}
|
}
|
||||||
let commitHashFull = "unknown";
|
let commitHashFull = "unknown";
|
||||||
@@ -71,7 +71,7 @@ function getBuildInfo() {
|
|||||||
commitHashFull = execSync("git rev-parse HEAD", {
|
commitHashFull = execSync("git rev-parse HEAD", {
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
}).trim();
|
}).trim();
|
||||||
} catch (_) {
|
} catch {
|
||||||
// not a git repo or git not available
|
// not a git repo or git not available
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -120,25 +120,6 @@ What gets sent: token symbol names (e.g. "ETH", "USDC"). No addresses, no
|
|||||||
balances, no identifying information. As with any request, CoinDesk sees your IP
|
balances, no identifying information. As with any request, CoinDesk sees your IP
|
||||||
address.
|
address.
|
||||||
|
|
||||||
**Phishing domain blocklist** (`raw.githubusercontent.com`)
|
|
||||||
|
|
||||||
A community-maintained list of phishing domains, used to warn you when a site
|
|
||||||
that asks to connect, or to have a transaction or signature approved, is a known
|
|
||||||
scam. A copy is bundled into the extension at build time, so the protection
|
|
||||||
works before any network request happens. At runtime the extension fetches the
|
|
||||||
live list to pick up newly added domains, keeping only the entries not already
|
|
||||||
in the bundled copy (persisted locally if under 256 KiB). This endpoint is not
|
|
||||||
user-configurable.
|
|
||||||
|
|
||||||
When it is contacted: when the background script starts, if the last fetch was
|
|
||||||
more than 24 hours ago, and every 24 hours after that. The time of the last
|
|
||||||
fetch is remembered across browser and background restarts, so restarting does
|
|
||||||
not cause a re-download. If a fetch fails, or the list is too large to keep, the
|
|
||||||
extension waits an hour before trying again outside that 24-hour schedule rather
|
|
||||||
than retrying on every restart. It is a plain download of a public file —
|
|
||||||
nothing about you is sent, but the host sees your IP address. If the fetch
|
|
||||||
fails, the bundled copy is still used.
|
|
||||||
|
|
||||||
**Etherscan address labels** (`etherscan.io`; `sepolia.etherscan.io` on Sepolia)
|
**Etherscan address labels** (`etherscan.io`; `sepolia.etherscan.io` on Sepolia)
|
||||||
|
|
||||||
When you review a send, AutistMask fetches the recipient's public Etherscan
|
When you review a send, AutistMask fetches the recipient's public Etherscan
|
||||||
@@ -367,8 +348,12 @@ confirmation screen. It contains only addresses involved in fraud -- it is not a
|
|||||||
sanctions list.
|
sanctions list.
|
||||||
|
|
||||||
**Phishing domain warnings.** Sites asking to connect or to have something
|
**Phishing domain warnings.** Sites asking to connect or to have something
|
||||||
approved are checked against the phishing domain blocklist described under
|
approved are checked against a community-maintained list of known phishing
|
||||||
External Services, and flagged with a red banner if they match.
|
domains, and flagged with a red banner if they match. The list is built into the
|
||||||
|
extension: the check is entirely local, so nobody is told which sites you visit,
|
||||||
|
and it works offline. It is also only as current as the release you are running
|
||||||
|
— a domain added to the list upstream reaches you in the next version of the
|
||||||
|
extension, not the same day.
|
||||||
|
|
||||||
The first four filters can be individually disabled in Settings if you prefer to
|
The first four filters can be individually disabled in Settings if you prefer to
|
||||||
see everything unfiltered.
|
see everything unfiltered.
|
||||||
|
|||||||
162
eslint.config.js
Normal file
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": "jest --forceExit",
|
||||||
"test:verbose": "jest --forceExit --verbose",
|
"test:verbose": "jest --forceExit --verbose",
|
||||||
"build": "node build.js",
|
"build": "node build.js",
|
||||||
"lint": "prettier --check .",
|
"lint": "eslint . && prettier --check .",
|
||||||
"fmt": "prettier --write .",
|
"fmt": "prettier --write .",
|
||||||
"fmt-check": "prettier --check ."
|
"fmt-check": "prettier --check ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "10.0.1",
|
||||||
"@tailwindcss/cli": "^4.2.1",
|
"@tailwindcss/cli": "^4.2.1",
|
||||||
"esbuild": "^0.27.3",
|
"esbuild": "^0.27.3",
|
||||||
|
"eslint": "10.8.1",
|
||||||
|
"globals": "17.11.0",
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"playwright-core": "1.56.0",
|
"playwright-core": "1.56.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
|||||||
main() {
|
main() {
|
||||||
"$SCRIPT_DIR/test"
|
"$SCRIPT_DIR/test"
|
||||||
"$SCRIPT_DIR/test-verify-build"
|
"$SCRIPT_DIR/test-verify-build"
|
||||||
|
"$SCRIPT_DIR/check-censored"
|
||||||
"$SCRIPT_DIR/lint"
|
"$SCRIPT_DIR/lint"
|
||||||
"$SCRIPT_DIR/fmt-check"
|
"$SCRIPT_DIR/fmt-check"
|
||||||
}
|
}
|
||||||
|
|||||||
301
script/check-censored
Executable file
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
|
#!/bin/sh
|
||||||
# script/lint: run the linter.
|
# script/lint: run the linter (eslint, then prettier --check).
|
||||||
|
#
|
||||||
|
# Linting is containerized. ESLint results depend on the ESLint version, and
|
||||||
|
# the pinned one is the one in the image; a host's own install must not be
|
||||||
|
# able to decide whether this repo is green. From a host this therefore builds
|
||||||
|
# the Dockerfile's `lint` stage, which runs this same script inside the image.
|
||||||
|
#
|
||||||
|
# AUTISTMASK_LINT_NATIVE is set only in that image (see the Dockerfile) and is
|
||||||
|
# what stops the recursion, so `make check` inside the CI build lints in place
|
||||||
|
# instead of trying to reach a docker daemon it does not have.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|
||||||
|
case "${AUTISTMASK_LINT_NATIVE:-}" in
|
||||||
|
1)
|
||||||
echo "Linting..."
|
echo "Linting..."
|
||||||
yarn run lint 2>&1
|
yarn run lint 2>&1
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
"") ;;
|
||||||
|
*)
|
||||||
|
# Set but not recognized: say so rather than silently taking the
|
||||||
|
# docker path, which would look like the variable had no effect.
|
||||||
|
echo "lint: AUTISTMASK_LINT_NATIVE is set to" \
|
||||||
|
"'${AUTISTMASK_LINT_NATIVE}'; the only recognized value is 1" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
echo "lint: docker is required; linting does not run on the host" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Linting in the pinned container..."
|
||||||
|
# --progress=plain: the default progress renderer collapses the lint
|
||||||
|
# output on success, and a lint run whose output cannot be seen is not
|
||||||
|
# evidence that it ran.
|
||||||
|
#
|
||||||
|
# --output=type=cacheonly: the exit status is the whole result; exporting
|
||||||
|
# an image afterwards costs about ten times the lint itself.
|
||||||
|
docker build --progress=plain --target lint \
|
||||||
|
--output=type=cacheonly . 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
38
script/test
38
script/test
@@ -1,19 +1,49 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/test: run the test suite.
|
# script/test: run the test suite.
|
||||||
|
#
|
||||||
|
# The timeout bounds a hung suite; it is not a performance budget. On a
|
||||||
|
# developer host the suite finishes in about 8s and REPO_POLICIES' 30s cap is
|
||||||
|
# the bound. Inside the image the same suite also pays a cold jest cache and
|
||||||
|
# shares the runner with the rest of the build, which is not what that budget
|
||||||
|
# describes, so the Dockerfile raises the bound through
|
||||||
|
# AUTISTMASK_TEST_TIMEOUT. A cap a healthy suite can trip on a cold cache
|
||||||
|
# produces a red that means nothing, and teaches "just run it again".
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
TIMEOUT="${AUTISTMASK_TEST_TIMEOUT:-30}"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
echo "Running tests..."
|
echo "Running tests (timeout ${TIMEOUT}s)..."
|
||||||
timeout 30 yarn run test 2>&1 || {
|
|
||||||
|
status=0
|
||||||
|
timeout "$TIMEOUT" yarn run test 2>&1 || status=$?
|
||||||
|
[ "$status" -eq 0 ] && return 0
|
||||||
|
|
||||||
|
# 124 is timeout(1) killing the suite. Say so: a kill is not a failed
|
||||||
|
# assertion, and the verbose rerun would only spend the same wall clock
|
||||||
|
# to be killed again.
|
||||||
|
if [ "$status" -eq 124 ]; then
|
||||||
|
echo "tests: TIMED OUT after ${TIMEOUT}s (no assertion failed)" >&2
|
||||||
|
echo "tests: raise AUTISTMASK_TEST_TIMEOUT if the suite is healthy" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 125 is timeout(1) itself failing, which here means AUTISTMASK_TEST_TIMEOUT
|
||||||
|
# is not a duration it accepts. The suite never ran, so it neither timed out
|
||||||
|
# nor failed, and the verbose rerun would only reprint the same complaint.
|
||||||
|
if [ "$status" -eq 125 ]; then
|
||||||
|
echo "tests: DID NOT RUN: timeout(1) rejected AUTISTMASK_TEST_TIMEOUT=\"${TIMEOUT}\"" >&2
|
||||||
|
echo "tests: set it to a duration such as 30 or 180 (see timeout(1))" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo "--- Rerunning with --verbose for details ---"
|
echo "--- Rerunning with --verbose for details ---"
|
||||||
timeout 30 yarn run test:verbose 2>&1 || true
|
timeout "$TIMEOUT" yarn run test:verbose 2>&1 || true
|
||||||
# Always fail: the first run already proved the tests are broken, so a
|
# Always fail: the first run already proved the tests are broken, so a
|
||||||
# flaky pass on the rerun must not turn the build green.
|
# flaky pass on the rerun must not turn the build green.
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
@@ -5,8 +5,9 @@
|
|||||||
#
|
#
|
||||||
# Deliberately NOT called by script/check or script/test: REPO_POLICIES.md
|
# Deliberately NOT called by script/check or script/test: REPO_POLICIES.md
|
||||||
# caps make test at 20 seconds and a browser suite does not fit. Run it
|
# caps make test at 20 seconds and a browser suite does not fit. Run it
|
||||||
# yourself before touching popup views; it is the only check that can see
|
# yourself before touching popup views. ESLint's no-undef now catches a
|
||||||
# a used-but-not-imported identifier blow up at runtime.
|
# used-but-not-imported identifier in make check, but only this suite sees
|
||||||
|
# what a view actually does when it runs.
|
||||||
# .gitea/workflows/e2e.yml also runs it on every push, in a job separate
|
# .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.
|
# from check so that cap and the local fast path both stay intact.
|
||||||
#
|
#
|
||||||
@@ -58,12 +59,12 @@ main() {
|
|||||||
# browser profile.
|
# browser profile.
|
||||||
# PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1: without it,
|
# PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1: without it,
|
||||||
# ctx.route() intercepts page requests only, and every fetch made by
|
# ctx.route() intercepts page requests only, and every fetch made by
|
||||||
# the MV3 background service worker — including the phishing
|
# the MV3 background service worker — the JSON-RPC calls behind
|
||||||
# blocklist fetch that src/background/index.js issues at worker
|
# every approval the suite drives among them — goes to the real
|
||||||
# startup — goes to the real internet. The flag is experimental and
|
# internet. The flag is experimental and Playwright may drop or
|
||||||
# Playwright may drop or rename it. It cannot break silently: the
|
# rename it. It cannot break silently: the harness asks the worker
|
||||||
# harness probes service-worker interception at launch and aborts
|
# for one request of its own at launch and aborts the whole suite
|
||||||
# the whole suite if it is not in effect (see the interception
|
# if it does not reach the route handler (see the interception
|
||||||
# canary in tests/e2e/harness.js). If a future Playwright removes
|
# canary in tests/e2e/harness.js). If a future Playwright removes
|
||||||
# the flag, that probe is what will fail, and the fix is either a
|
# the flag, that probe is what will fail, and the fix is either a
|
||||||
# replacement mechanism or an honest downgrade of the isolation
|
# replacement mechanism or an honest downgrade of the isolation
|
||||||
|
|||||||
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 "$@"
|
||||||
@@ -27,30 +27,29 @@ const {
|
|||||||
TX_STAGE_NONCE,
|
TX_STAGE_NONCE,
|
||||||
} = require("../shared/approvalVerify");
|
} = require("../shared/approvalVerify");
|
||||||
const { prepareApprovalTx } = require("../shared/approvalTx");
|
const { prepareApprovalTx } = require("../shared/approvalTx");
|
||||||
const {
|
const { isPhishingDomain } = require("../shared/phishingDomains");
|
||||||
isPhishingDomain,
|
|
||||||
refreshPhishingListOnSchedule,
|
|
||||||
initPhishingList,
|
|
||||||
} = require("../shared/phishingDomains");
|
|
||||||
const {
|
const {
|
||||||
BALANCE_REFRESH_ALARM,
|
BALANCE_REFRESH_ALARM,
|
||||||
PHISHING_REFRESH_ALARM,
|
|
||||||
BALANCE_REFRESH_PERIOD_MINUTES,
|
BALANCE_REFRESH_PERIOD_MINUTES,
|
||||||
ensureRecurringAlarms,
|
ensureRecurringAlarms,
|
||||||
registerAlarmHandlers,
|
registerAlarmHandlers,
|
||||||
} = require("../shared/alarms");
|
} = require("../shared/alarms");
|
||||||
|
|
||||||
const storageApi =
|
const {
|
||||||
typeof browser !== "undefined"
|
actionApi,
|
||||||
? browser.storage.local
|
runtimeApi,
|
||||||
: chrome.storage.local;
|
storageGet,
|
||||||
const runtime =
|
tabsQuery,
|
||||||
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
|
tabsSendMessage,
|
||||||
const windowsApi =
|
windowsApi,
|
||||||
typeof browser !== "undefined" ? browser.windows : chrome.windows;
|
windowsCreate,
|
||||||
const tabsApi = typeof browser !== "undefined" ? browser.tabs : chrome.tabs;
|
windowsGetLastFocused,
|
||||||
const actionApi =
|
windowsRemove,
|
||||||
typeof browser !== "undefined" ? browser.browserAction : chrome.action;
|
} = require("../shared/browserApi");
|
||||||
|
|
||||||
|
const runtime = runtimeApi();
|
||||||
|
const windowsNs = windowsApi();
|
||||||
|
const actionNs = actionApi();
|
||||||
|
|
||||||
// Connected sites (in-memory, non-persisted): { "origin:address": true }
|
// Connected sites (in-memory, non-persisted): { "origin:address": true }
|
||||||
const connectedSites = {};
|
const connectedSites = {};
|
||||||
@@ -166,8 +165,18 @@ function approvedNonce(approvedTx) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What the page is told when a request failed in a way the wallet has no
|
||||||
|
// specific answer for. -32603 is the JSON-RPC internal error EIP-1474 defines
|
||||||
|
// and EIP-1193 defers to for RPC-layer failures; no EIP-1193 4xxx code
|
||||||
|
// describes "the wallet broke", and one is not invented here. The cause is
|
||||||
|
// logged rather than put in the message: the page gets a stable sentence, the
|
||||||
|
// background console gets the throw.
|
||||||
|
const INTERNAL_ERROR_CODE = -32603;
|
||||||
|
const INTERNAL_ERROR_MESSAGE =
|
||||||
|
"AutistMask could not complete this request because of an internal error.";
|
||||||
|
|
||||||
async function getState() {
|
async function getState() {
|
||||||
const result = await storageApi.get("autistmask");
|
const result = await storageGet("autistmask");
|
||||||
return (
|
return (
|
||||||
result.autistmask || {
|
result.autistmask || {
|
||||||
wallets: [],
|
wallets: [],
|
||||||
@@ -231,8 +240,8 @@ async function proxyRpc(method, params) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetPopupUrl() {
|
function resetPopupUrl() {
|
||||||
if (actionApi && typeof actionApi.setPopup === "function") {
|
if (actionNs && typeof actionNs.setPopup === "function") {
|
||||||
actionApi.setPopup({ popup: "src/popup/index.html" });
|
actionNs.setPopup({ popup: "src/popup/index.html" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,12 +345,21 @@ function releaseApproval(approval) {
|
|||||||
// Open approval in a separate popup window.
|
// Open approval in a separate popup window.
|
||||||
// This is the primary mechanism for tx/sign approvals (triggered programmatically,
|
// This is the primary mechanism for tx/sign approvals (triggered programmatically,
|
||||||
// not from a user gesture) and the fallback for site-connection approvals.
|
// not from a user gesture) and the fallback for site-connection approvals.
|
||||||
function openApprovalWindow(id) {
|
// Never rejects. Its callers raise it from inside a Promise executor and drop
|
||||||
|
// the result on the floor, so a rejection here would be unhandled.
|
||||||
|
async function openApprovalWindow(id) {
|
||||||
const popupUrl = runtime.getURL("src/popup/index.html?approval=" + id);
|
const popupUrl = runtime.getURL("src/popup/index.html?approval=" + id);
|
||||||
const popupWidth = 360;
|
const popupWidth = 360;
|
||||||
const popupHeight = 600;
|
const popupHeight = 600;
|
||||||
|
|
||||||
windowsApi.getLastFocused((currentWin) => {
|
let currentWin = null;
|
||||||
|
try {
|
||||||
|
currentWin = await windowsGetLastFocused();
|
||||||
|
} catch {
|
||||||
|
// Nothing focused to centre on. The window still opens, at whatever
|
||||||
|
// position the browser picks.
|
||||||
|
}
|
||||||
|
|
||||||
const opts = {
|
const opts = {
|
||||||
url: popupUrl,
|
url: popupUrl,
|
||||||
type: "popup",
|
type: "popup",
|
||||||
@@ -356,25 +374,31 @@ function openApprovalWindow(id) {
|
|||||||
currentWin.top + (currentWin.height - popupHeight) / 2,
|
currentWin.top + (currentWin.height - popupHeight) / 2,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
windowsApi.create(opts, (win) => {
|
|
||||||
|
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];
|
const approval = pendingApprovals[id];
|
||||||
if (!approval) {
|
if (!approval) {
|
||||||
// Settled while the window was opening — an address switch,
|
// Settled while the window was opening — an address switch, say.
|
||||||
// say. Nothing is waiting on it, and a window showing an
|
// Nothing is waiting on it, and a window showing an approval that no
|
||||||
// approval that no longer exists is not left on screen.
|
// longer exists is not left on screen. The await above makes this a
|
||||||
if (win) {
|
// real race: writing the id back would resurrect a bare entry that
|
||||||
windowsApi.remove(win.id, () => {
|
// nothing would ever resolve.
|
||||||
if (runtime.lastError) {
|
if (win) windowsRemove(win.id).catch(() => {});
|
||||||
// window already closed
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!win) {
|
if (!win) {
|
||||||
// No window means no way to ever answer this approval, and an
|
// No window means no way to ever answer this approval, and an
|
||||||
// approval nothing can answer holds the requesting page's
|
// approval nothing can answer holds the requesting page's promise
|
||||||
// promise open forever. Settle it now instead.
|
// open forever. Settle it now instead.
|
||||||
settleApproval(
|
settleApproval(
|
||||||
id,
|
id,
|
||||||
abandonedResult(
|
abandonedResult(
|
||||||
@@ -386,8 +410,6 @@ function openApprovalWindow(id) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
approval.windowId = win.id;
|
approval.windowId = win.id;
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open an approval popup and return a promise that resolves with the user decision.
|
// Open an approval popup and return a promise that resolves with the user decision.
|
||||||
@@ -397,12 +419,12 @@ function requestApproval(origin, hostname) {
|
|||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
pendingApprovals[id] = { id, origin, hostname, resolve };
|
pendingApprovals[id] = { id, origin, hostname, resolve };
|
||||||
|
|
||||||
if (actionApi && typeof actionApi.openPopup === "function") {
|
if (actionNs && typeof actionNs.openPopup === "function") {
|
||||||
actionApi.setPopup({
|
actionNs.setPopup({
|
||||||
popup: "src/popup/index.html?approval=" + id,
|
popup: "src/popup/index.html?approval=" + id,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const result = actionApi.openPopup();
|
const result = actionNs.openPopup();
|
||||||
if (result && typeof result.catch === "function") {
|
if (result && typeof result.catch === "function") {
|
||||||
result.catch(() => openApprovalWindow(id));
|
result.catch(() => openApprovalWindow(id));
|
||||||
}
|
}
|
||||||
@@ -467,13 +489,53 @@ function requestSignApproval(origin, hostname, signParams, approvedFrom) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect when an approval popup (browser-action) closes without a response.
|
// Anything only the extension's own pages may say. A content script speaks
|
||||||
// TX and sign approvals now use windows.create() and are handled by the
|
// with the page's URL, so this is what separates the popup from the site the
|
||||||
// windowsApi.onRemoved listener below, but we still handle site-connection
|
// popup is being asked about.
|
||||||
// approval disconnects here.
|
function isExtensionSender(sender) {
|
||||||
|
const extUrl = runtime.getURL("");
|
||||||
|
return !!(sender && sender.url && sender.url.startsWith(extUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The approval popup's port: it carries the user's decision on a
|
||||||
|
// site-connection approval, and its disconnect is how that approval learns the
|
||||||
|
// popup closed without one.
|
||||||
|
//
|
||||||
|
// The decision travels this port rather than a one-off runtime.sendMessage()
|
||||||
|
// for exactly one reason: the port is also what the popup's window.close()
|
||||||
|
// disconnects. A message posted on a port is delivered before that port's
|
||||||
|
// disconnect, so approve-then-close settles as an approval no matter how fast
|
||||||
|
// the teardown is. Sent as a one-off message the two crossed on independent
|
||||||
|
// channels with nothing ordering them, and the teardown won every time when
|
||||||
|
// the prompt was driven in a tab: the user approved and the dApp was told they
|
||||||
|
// had refused.
|
||||||
|
//
|
||||||
|
// TX and sign approvals do not decide here. They stay pending across a
|
||||||
|
// disconnect — the user can reopen the toolbar popup — and are rejected by the
|
||||||
|
// windows.onRemoved listener below.
|
||||||
runtime.onConnect.addListener((port) => {
|
runtime.onConnect.addListener((port) => {
|
||||||
if (port.name.startsWith("approval:")) {
|
if (port.name.startsWith("approval:")) {
|
||||||
const id = port.name.split(":")[1];
|
const id = port.name.split(":")[1];
|
||||||
|
if (pendingApprovals[id] && isExtensionSender(port.sender)) {
|
||||||
|
// The extension's own popup is on the other end, so its disconnect
|
||||||
|
// is a trustworthy "closed" and onRemoved below stands down. The
|
||||||
|
// sender check is what keeps that from being an off switch: a
|
||||||
|
// content script that guessed the id and held its port open would
|
||||||
|
// otherwise disable the only settlement path a prompt whose popup
|
||||||
|
// never connected has left, and the dApp would wait forever.
|
||||||
|
pendingApprovals[id].portConnected = true;
|
||||||
|
}
|
||||||
|
port.onMessage.addListener((msg) => {
|
||||||
|
if (!msg || msg.type !== "AUTISTMASK_APPROVAL_DECISION") return;
|
||||||
|
if (!isExtensionSender(port.sender)) return;
|
||||||
|
const approval = pendingApprovals[id];
|
||||||
|
if (!approval || approval.type === "tx" || approval.type === "sign")
|
||||||
|
return;
|
||||||
|
settleApproval(id, {
|
||||||
|
approved: !!msg.approved,
|
||||||
|
remember: !!msg.remember,
|
||||||
|
});
|
||||||
|
});
|
||||||
port.onDisconnect.addListener(() => {
|
port.onDisconnect.addListener(() => {
|
||||||
const approval = pendingApprovals[id];
|
const approval = pendingApprovals[id];
|
||||||
if (approval) {
|
if (approval) {
|
||||||
@@ -483,7 +545,6 @@ runtime.onConnect.addListener((port) => {
|
|||||||
}
|
}
|
||||||
settleApproval(id, { approved: false, remember: false });
|
settleApproval(id, { approved: false, remember: false });
|
||||||
}
|
}
|
||||||
resetPopupUrl();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -883,24 +944,26 @@ async function handleSendTransaction(params, origin) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Broadcast chainChanged to all tabs when the network is switched.
|
// Broadcast chainChanged to all tabs when the network is switched.
|
||||||
function broadcastChainChanged(chainId) {
|
//
|
||||||
tabsApi.query({}, (tabs) => {
|
// Never rejects: its caller is an RPC handler that must answer the page
|
||||||
|
// whatever the browser made of the broadcast.
|
||||||
|
async function broadcastChainChanged(chainId) {
|
||||||
|
let tabs;
|
||||||
|
try {
|
||||||
|
tabs = await tabsQuery({});
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (const tab of tabs) {
|
for (const tab of tabs) {
|
||||||
tabsApi.sendMessage(
|
// A tab with no content script has no receiver, and that is the
|
||||||
tab.id,
|
// ordinary case rather than a fault. The rejection it produces is the
|
||||||
{
|
// promise-shaped form of the runtime.lastError this used to read.
|
||||||
|
tabsSendMessage(tab.id, {
|
||||||
type: "AUTISTMASK_EVENT",
|
type: "AUTISTMASK_EVENT",
|
||||||
eventName: "chainChanged",
|
eventName: "chainChanged",
|
||||||
data: chainId,
|
data: chainId,
|
||||||
},
|
}).catch(() => {});
|
||||||
() => {
|
|
||||||
if (runtime.lastError) {
|
|
||||||
// expected for tabs without our content script
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Broadcast accountsChanged to all tabs, respecting per-address permissions
|
// Broadcast accountsChanged to all tabs, respecting per-address permissions
|
||||||
@@ -921,18 +984,21 @@ async function broadcastAccountsChanged() {
|
|||||||
);
|
);
|
||||||
if (!settleApproval(id, rejection)) continue;
|
if (!settleApproval(id, rejection)) continue;
|
||||||
if (approval.windowId) {
|
if (approval.windowId) {
|
||||||
windowsApi.remove(approval.windowId, () => {
|
// Rejects when the window has already gone, which is a race the
|
||||||
if (runtime.lastError) {
|
// user wins routinely by closing it themselves.
|
||||||
// window already closed
|
windowsRemove(approval.windowId).catch(() => {});
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
resetPopupUrl();
|
resetPopupUrl();
|
||||||
const s = await getState();
|
const s = await getState();
|
||||||
const activeAddress = await getActiveAddress();
|
const activeAddress = await getActiveAddress();
|
||||||
const allowed = activeAddress ? s.allowedSites[activeAddress] || [] : [];
|
const allowed = activeAddress ? s.allowedSites[activeAddress] || [] : [];
|
||||||
tabsApi.query({}, (tabs) => {
|
let tabs;
|
||||||
|
try {
|
||||||
|
tabs = await tabsQuery({});
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (const tab of tabs) {
|
for (const tab of tabs) {
|
||||||
const origin = tab.url ? new URL(tab.url).origin : "";
|
const origin = tab.url ? new URL(tab.url).origin : "";
|
||||||
const hostname = extractHostname(origin);
|
const hostname = extractHostname(origin);
|
||||||
@@ -940,22 +1006,14 @@ async function broadcastAccountsChanged() {
|
|||||||
activeAddress &&
|
activeAddress &&
|
||||||
(allowed.includes(hostname) ||
|
(allowed.includes(hostname) ||
|
||||||
connectedSites[origin + ":" + activeAddress]);
|
connectedSites[origin + ":" + activeAddress]);
|
||||||
tabsApi.sendMessage(
|
// Same as chainChanged above: a tab without our content script
|
||||||
tab.id,
|
// rejects, and that is expected rather than a fault.
|
||||||
{
|
tabsSendMessage(tab.id, {
|
||||||
type: "AUTISTMASK_EVENT",
|
type: "AUTISTMASK_EVENT",
|
||||||
eventName: "accountsChanged",
|
eventName: "accountsChanged",
|
||||||
data: hasPermission ? [activeAddress] : [],
|
data: hasPermission ? [activeAddress] : [],
|
||||||
},
|
}).catch(() => {});
|
||||||
() => {
|
|
||||||
// Ignore errors for tabs without content script
|
|
||||||
if (runtime.lastError) {
|
|
||||||
// expected for tabs without our content script
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Background balance refresh: every 60 seconds when the popup isn't open.
|
// Background balance refresh: every 60 seconds when the popup isn't open.
|
||||||
@@ -989,26 +1047,20 @@ async function backgroundRefresh() {
|
|||||||
await saveState();
|
await saveState();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Both recurring jobs run off alarms, not timers. On Chrome MV3 this file is
|
// The recurring job runs off an alarm, not a timer. On Chrome MV3 this file is
|
||||||
// a service worker that the browser terminates after about 30 seconds idle,
|
// a service worker that the browser terminates after about 30 seconds idle,
|
||||||
// so a setInterval would only ever survive until the first idle period and
|
// so a setInterval would only ever survive until the first idle period and
|
||||||
// module-level state does not outlive it. Alarms are held by the browser and
|
// module-level state does not outlive it. Alarms are held by the browser and
|
||||||
// wake the worker to deliver them.
|
// wake the worker to deliver them.
|
||||||
registerAlarmHandlers({
|
registerAlarmHandlers({
|
||||||
[BALANCE_REFRESH_ALARM]: backgroundRefresh,
|
[BALANCE_REFRESH_ALARM]: backgroundRefresh,
|
||||||
// The scheduled refresh, which restores persisted state on a freshly
|
|
||||||
// revived worker and then fetches unconditionally. The freshness guards
|
|
||||||
// belong to the startup path; applying them here would make the tick skip
|
|
||||||
// itself.
|
|
||||||
[PHISHING_REFRESH_ALARM]: refreshPhishingListOnSchedule,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Everything the background context needs re-established on start. This runs
|
// Everything the background context needs re-established on start. This runs
|
||||||
// on a fresh install, on browser startup, and on every revival of a
|
// on a fresh install, on browser startup, and on every revival of a
|
||||||
// terminated worker, so it must be idempotent: ensureRecurringAlarms() only
|
// terminated worker, so it must be idempotent: ensureRecurringAlarms() only
|
||||||
// creates alarms that are missing or carrying a stale period, and
|
// creates alarms that are missing or carrying a stale period, and only clears
|
||||||
// initPhishingList() fetches only when the persisted timestamps say the list
|
// retired ones that are still registered.
|
||||||
// is stale.
|
|
||||||
//
|
//
|
||||||
// On a fresh install the top-level call and the onInstalled listener both run,
|
// On a fresh install the top-level call and the onInstalled listener both run,
|
||||||
// close enough together that both could see an alarm missing and create it.
|
// close enough together that both could see an alarm missing and create it.
|
||||||
@@ -1019,10 +1071,7 @@ let backgroundJobsRun = null;
|
|||||||
|
|
||||||
function startBackgroundJobs() {
|
function startBackgroundJobs() {
|
||||||
if (backgroundJobsRun) return backgroundJobsRun;
|
if (backgroundJobsRun) return backgroundJobsRun;
|
||||||
backgroundJobsRun = Promise.all([
|
backgroundJobsRun = ensureRecurringAlarms()
|
||||||
ensureRecurringAlarms(),
|
|
||||||
initPhishingList(),
|
|
||||||
])
|
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
// An alarm that failed to schedule means a recurring job silently
|
// An alarm that failed to schedule means a recurring job silently
|
||||||
// never runs again; it must not be an unhandled rejection.
|
// never runs again; it must not be an unhandled rejection.
|
||||||
@@ -1050,10 +1099,21 @@ startBackgroundJobs();
|
|||||||
// outcome to the page — and the window is recorded as gone, so that an attempt
|
// 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
|
// which then fails retryably settles instead of waiting in a window that no
|
||||||
// longer exists.
|
// longer exists.
|
||||||
if (windowsApi && windowsApi.onRemoved) {
|
//
|
||||||
windowsApi.onRemoved.addListener((windowId) => {
|
// A site-connection approval whose popup connected its port is not decided
|
||||||
|
// here. That popup approves and closes in the same breath, and this event
|
||||||
|
// races the decision on a channel of its own — the same race the port exists
|
||||||
|
// to end. Its port disconnect says the same thing this event does, in an order
|
||||||
|
// that is defined, so the disconnect is left to say it. The window closing
|
||||||
|
// before any port connected is the one case with nothing else to speak for it,
|
||||||
|
// and is rejected here so the dApp is not left waiting on a window that is
|
||||||
|
// gone.
|
||||||
|
if (windowsNs && windowsNs.onRemoved) {
|
||||||
|
windowsNs.onRemoved.addListener((windowId) => {
|
||||||
for (const [id, approval] of Object.entries(pendingApprovals)) {
|
for (const [id, approval] of Object.entries(pendingApprovals)) {
|
||||||
if (approval.windowId !== windowId) continue;
|
if (approval.windowId !== windowId) continue;
|
||||||
|
const isSite = approval.type !== "tx" && approval.type !== "sign";
|
||||||
|
if (isSite && approval.portConnected) continue;
|
||||||
const rejection = abandonedResult(
|
const rejection = abandonedResult(
|
||||||
approval,
|
approval,
|
||||||
APPROVAL_REJECTED_CODE,
|
APPROVAL_REJECTED_CODE,
|
||||||
@@ -1079,26 +1139,41 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
// keep fallback
|
// keep fallback
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
handleRpc(msg.method, msg.params, trustedOrigin).then((response) => {
|
handleRpc(msg.method, msg.params, trustedOrigin)
|
||||||
|
.then((response) => {
|
||||||
sendResponse(response);
|
sendResponse(response);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
// Without this the page's window.ethereum.request() promise
|
||||||
|
// stays pending forever: no response is sent, the content
|
||||||
|
// script posts nothing back, and the dApp cannot tell the
|
||||||
|
// failure from a slow wallet. handleRpc does real work —
|
||||||
|
// state loads, provider calls, transaction population — so
|
||||||
|
// "it does not throw today" is not a property anyone is
|
||||||
|
// maintaining.
|
||||||
|
log.errorf("RPC request failed:", msg.method, err);
|
||||||
|
sendResponse({
|
||||||
|
error: {
|
||||||
|
code: INTERNAL_ERROR_CODE,
|
||||||
|
message: INTERNAL_ERROR_MESSAGE,
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate that popup-only messages originate from the extension itself.
|
// Validate that popup-only messages originate from the extension itself.
|
||||||
|
// The site-connection decision is not here: it is a port message, and it
|
||||||
|
// is checked the same way where the port is served.
|
||||||
const POPUP_ONLY_TYPES = [
|
const POPUP_ONLY_TYPES = [
|
||||||
"AUTISTMASK_GET_APPROVAL",
|
"AUTISTMASK_GET_APPROVAL",
|
||||||
"AUTISTMASK_APPROVAL_RESPONSE",
|
|
||||||
"AUTISTMASK_TX_RESPONSE",
|
"AUTISTMASK_TX_RESPONSE",
|
||||||
"AUTISTMASK_SIGN_RESPONSE",
|
"AUTISTMASK_SIGN_RESPONSE",
|
||||||
];
|
];
|
||||||
if (POPUP_ONLY_TYPES.includes(msg.type)) {
|
if (POPUP_ONLY_TYPES.includes(msg.type) && !isExtensionSender(sender)) {
|
||||||
const extUrl = runtime.getURL("");
|
|
||||||
if (!sender.url || !sender.url.startsWith(extUrl)) {
|
|
||||||
sendResponse({ error: "Unauthorized sender" });
|
sendResponse({ error: "Unauthorized sender" });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.type === "AUTISTMASK_GET_APPROVAL") {
|
if (msg.type === "AUTISTMASK_GET_APPROVAL") {
|
||||||
const approval = pendingApprovals[msg.id];
|
const approval = pendingApprovals[msg.id];
|
||||||
@@ -1129,15 +1204,6 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.type === "AUTISTMASK_APPROVAL_RESPONSE") {
|
|
||||||
settleApproval(msg.id, {
|
|
||||||
approved: msg.approved,
|
|
||||||
remember: msg.remember,
|
|
||||||
});
|
|
||||||
resetPopupUrl();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.type === "AUTISTMASK_TX_RESPONSE") {
|
if (msg.type === "AUTISTMASK_TX_RESPONSE") {
|
||||||
const approval = pendingApprovals[msg.id];
|
const approval = pendingApprovals[msg.id];
|
||||||
if (!approval) return false;
|
if (!approval) return false;
|
||||||
@@ -1188,6 +1254,10 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Which phase the last-resort .catch() below reports. Everything up to
|
||||||
|
// the broadcastTransaction() call provably never reached the network,
|
||||||
|
// so an escape from there must not tell the user it might have.
|
||||||
|
let lastResortStage = TX_STAGE_VERIFY;
|
||||||
(async () => {
|
(async () => {
|
||||||
// The chain this attempt is on, read once. Verification below
|
// The chain this attempt is on, read once. Verification below
|
||||||
// refuses an artifact signed for any other chain, and the nonce
|
// refuses an artifact signed for any other chain, and the nonce
|
||||||
@@ -1271,6 +1341,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const provider = getProvider(state.rpcUrl);
|
const provider = getProvider(state.rpcUrl);
|
||||||
|
lastResortStage = TX_STAGE_BROADCAST;
|
||||||
const tx = await provider.broadcastTransaction(msg.rawSignedTx);
|
const tx = await provider.broadcastTransaction(msg.rawSignedTx);
|
||||||
if (nonce !== null) spent.add(nonce);
|
if (nonce !== null) spent.add(nonce);
|
||||||
settleApproval(
|
settleApproval(
|
||||||
@@ -1302,7 +1373,28 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
stage: outcome.stage,
|
stage: outcome.stage,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})();
|
})().catch((e) => {
|
||||||
|
// Every statement above is inside a try, but a throw from one of
|
||||||
|
// the catch blocks escapes as an unhandled rejection and neither
|
||||||
|
// the popup nor the page is ever answered. Settle both, through
|
||||||
|
// the same chokepoint as every other retirement.
|
||||||
|
log.errorf("transaction approval response failed:", e);
|
||||||
|
settleApproval(
|
||||||
|
msg.id,
|
||||||
|
{
|
||||||
|
error: {
|
||||||
|
code: INTERNAL_ERROR_CODE,
|
||||||
|
message: INTERNAL_ERROR_MESSAGE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ holdsClaim: true },
|
||||||
|
);
|
||||||
|
sendResponse({
|
||||||
|
error: INTERNAL_ERROR_MESSAGE,
|
||||||
|
retryable: false,
|
||||||
|
stage: lastResortStage,
|
||||||
|
});
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1386,7 +1478,25 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
}
|
}
|
||||||
sendResponse({ error: errMsg, retryable });
|
sendResponse({ error: errMsg, retryable });
|
||||||
}
|
}
|
||||||
})();
|
})().catch((e) => {
|
||||||
|
// Same shape as the transaction path: a throw out of the catch
|
||||||
|
// block above would leave the popup and the page both waiting.
|
||||||
|
log.errorf("sign approval response failed:", e);
|
||||||
|
settleApproval(
|
||||||
|
msg.id,
|
||||||
|
{
|
||||||
|
error: {
|
||||||
|
code: INTERNAL_ERROR_CODE,
|
||||||
|
message: INTERNAL_ERROR_MESSAGE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ holdsClaim: true },
|
||||||
|
);
|
||||||
|
sendResponse({
|
||||||
|
error: INTERNAL_ERROR_MESSAGE,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
// AutistMask content script — bridges between inpage (window.ethereum)
|
// AutistMask content script — bridges between inpage (window.ethereum)
|
||||||
// and the background service worker via extension messaging.
|
// and the background service worker via extension messaging.
|
||||||
|
|
||||||
|
const {
|
||||||
|
hasBrowserNamespace,
|
||||||
|
runtimeApi,
|
||||||
|
sendMessage,
|
||||||
|
storageGet,
|
||||||
|
storageSet,
|
||||||
|
} = require("../shared/browserApi");
|
||||||
|
|
||||||
// In Chrome (MV3), inpage.js runs as a MAIN-world content script declared
|
// In Chrome (MV3), inpage.js runs as a MAIN-world content script declared
|
||||||
// in the manifest, so no injection is needed here. In Firefox (MV2), the
|
// in the manifest, so no injection is needed here. In Firefox (MV2), the
|
||||||
// "world" key is not supported, so we inject via a <script> tag.
|
// "world" key is not supported, so we inject via a <script> tag.
|
||||||
if (typeof browser !== "undefined") {
|
if (hasBrowserNamespace()) {
|
||||||
const script = document.createElement("script");
|
const script = document.createElement("script");
|
||||||
script.src = browser.runtime.getURL("src/content/inpage.js");
|
script.src = runtimeApi().getURL("src/content/inpage.js");
|
||||||
script.onload = function () {
|
script.onload = function () {
|
||||||
this.remove();
|
this.remove();
|
||||||
};
|
};
|
||||||
@@ -14,23 +22,27 @@ if (typeof browser !== "undefined") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send the persisted EIP-6963 provider UUID to the inpage script.
|
// Send the persisted EIP-6963 provider UUID to the inpage script.
|
||||||
// Generated once at install time and stored in chrome.storage.local.
|
// Generated once at install time and stored in extension storage.
|
||||||
(function sendProviderUuid() {
|
(async function sendProviderUuid() {
|
||||||
const storage =
|
let uuid = null;
|
||||||
typeof browser !== "undefined"
|
try {
|
||||||
? browser.storage.local
|
const items = await storageGet("eip6963Uuid");
|
||||||
: chrome.storage.local;
|
uuid = items?.eip6963Uuid;
|
||||||
storage.get("eip6963Uuid", (items) => {
|
|
||||||
let uuid = items?.eip6963Uuid;
|
|
||||||
if (!uuid) {
|
if (!uuid) {
|
||||||
uuid = crypto.randomUUID();
|
uuid = crypto.randomUUID();
|
||||||
storage.set({ eip6963Uuid: uuid });
|
await storageSet({ eip6963Uuid: uuid });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Storage was unavailable or refused the write. The announcement
|
||||||
|
// still has to go out — a provider that never announces is invisible
|
||||||
|
// to every EIP-6963 dApp — so it goes under a fresh uuid that this
|
||||||
|
// page load will not outlive.
|
||||||
|
if (!uuid) uuid = crypto.randomUUID();
|
||||||
}
|
}
|
||||||
window.postMessage(
|
window.postMessage(
|
||||||
{ type: "AUTISTMASK_PROVIDER_UUID", uuid },
|
{ type: "AUTISTMASK_PROVIDER_UUID", uuid },
|
||||||
location.origin,
|
location.origin,
|
||||||
);
|
);
|
||||||
});
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Relay requests from the page to the background script
|
// Relay requests from the page to the background script
|
||||||
@@ -39,27 +51,31 @@ window.addEventListener("message", (event) => {
|
|||||||
if (event.data?.type !== "AUTISTMASK_REQUEST") return;
|
if (event.data?.type !== "AUTISTMASK_REQUEST") return;
|
||||||
const { id, method, params } = event.data;
|
const { id, method, params } = event.data;
|
||||||
|
|
||||||
const runtime =
|
sendMessage({
|
||||||
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
|
type: "AUTISTMASK_RPC",
|
||||||
|
id,
|
||||||
runtime.sendMessage(
|
method,
|
||||||
{ type: "AUTISTMASK_RPC", id, method, params, origin: location.origin },
|
params,
|
||||||
(response) => {
|
origin: location.origin,
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
if (response) {
|
if (response) {
|
||||||
window.postMessage(
|
window.postMessage(
|
||||||
{ type: "AUTISTMASK_RESPONSE", id, ...response },
|
{ type: "AUTISTMASK_RESPONSE", id, ...response },
|
||||||
"*",
|
"*",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
);
|
.catch(() => {
|
||||||
|
// No receiver: the background context is gone. The page's promise
|
||||||
|
// stays pending, which is what it did before this was a promise
|
||||||
|
// at all; turning it into a rejection here is a change to what
|
||||||
|
// dApps see and belongs to its own issue.
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for events pushed from the background (e.g. accountsChanged)
|
// Listen for events pushed from the background (e.g. accountsChanged)
|
||||||
const runtime =
|
runtimeApi().onMessage.addListener((msg) => {
|
||||||
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
|
|
||||||
|
|
||||||
runtime.onMessage.addListener((msg) => {
|
|
||||||
if (msg.type === "AUTISTMASK_EVENT") {
|
if (msg.type === "AUTISTMASK_EVENT") {
|
||||||
window.postMessage(
|
window.postMessage(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -79,7 +79,7 @@
|
|||||||
for (const cb of cbs) {
|
for (const cb of cbs) {
|
||||||
try {
|
try {
|
||||||
cb(data);
|
cb(data);
|
||||||
} catch (e) {
|
} catch {
|
||||||
// ignore listener errors
|
// ignore listener errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,7 +179,8 @@
|
|||||||
return this;
|
return this;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Some dApps (wagmi) check this to confirm MetaMask-like behavior
|
// Some dApps (wagmi) probe this object to decide whether the provider
|
||||||
|
// supports the de-facto standard extras. The name is theirs, not ours.
|
||||||
_metamask: {
|
_metamask: {
|
||||||
isUnlocked() {
|
isUnlocked() {
|
||||||
return Promise.resolve(provider.selectedAddress !== null);
|
return Promise.resolve(provider.selectedAddress !== null);
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ const {
|
|||||||
setBackRenderer,
|
setBackRenderer,
|
||||||
pushCurrentView,
|
pushCurrentView,
|
||||||
goBack,
|
goBack,
|
||||||
clearViewStack,
|
|
||||||
} = require("./views/helpers");
|
} = require("./views/helpers");
|
||||||
const { applyTheme } = require("./theme");
|
const { applyTheme } = require("./theme");
|
||||||
// Renders a view the popup lands on without having navigated to it forward:
|
// Renders a view the popup lands on without having navigated to it forward:
|
||||||
@@ -161,6 +160,12 @@ async function init() {
|
|||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const approvalId = params.get("approval");
|
const approvalId = params.get("approval");
|
||||||
if (approvalId) {
|
if (approvalId) {
|
||||||
|
// Deliberately not awaited, and deliberately not .catch()ed. show()
|
||||||
|
// is async, so a throw past its first await surfaces as an unhandled
|
||||||
|
// rejection rather than an uncaught error — measured as still failing
|
||||||
|
// the run on both harnesses (Playwright `pageerror`, and the Firefox
|
||||||
|
// driver's console-service drain), so nothing is lost by leaving it
|
||||||
|
// on that path.
|
||||||
approval.show(approvalId);
|
approval.show(approvalId);
|
||||||
showView("approve-site");
|
showView("approve-site");
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ async function importPrivateKey(ctx) {
|
|||||||
let addr;
|
let addr;
|
||||||
try {
|
try {
|
||||||
addr = addressFromPrivateKey(key);
|
addr = addressFromPrivateKey(key);
|
||||||
} catch (e) {
|
} catch {
|
||||||
showFlash("Invalid private key.");
|
showFlash("Invalid private key.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -246,7 +246,7 @@ async function importXprvKey(ctx) {
|
|||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
result = hdWalletFromXprv(xprv);
|
result = hdWalletFromXprv(xprv);
|
||||||
} catch (e) {
|
} catch {
|
||||||
showFlash(
|
showFlash(
|
||||||
"That extended private key is not valid. Please check it and try again.",
|
"That extended private key is not valid. Please check it and try again.",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const {
|
|||||||
goBack,
|
goBack,
|
||||||
pushCurrentView,
|
pushCurrentView,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state, currentAddress, saveState } = require("../../shared/state");
|
const { state, saveState } = require("../../shared/state");
|
||||||
const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
|
const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
|
||||||
const {
|
const {
|
||||||
fetchRecentTransactions,
|
fetchRecentTransactions,
|
||||||
@@ -44,7 +44,6 @@ function show() {
|
|||||||
state.selectedToken = null;
|
state.selectedToken = null;
|
||||||
const wallet = state.wallets[state.selectedWallet];
|
const wallet = state.wallets[state.selectedWallet];
|
||||||
const addr = wallet.addresses[state.selectedAddress];
|
const addr = wallet.addresses[state.selectedAddress];
|
||||||
const wi = state.selectedWallet;
|
|
||||||
const ai = state.selectedAddress;
|
const ai = state.selectedAddress;
|
||||||
$("address-title").textContent =
|
$("address-title").textContent =
|
||||||
wallet.name + " \u2014 Address " + (ai + 1);
|
wallet.name + " \u2014 Address " + (ai + 1);
|
||||||
@@ -246,7 +245,6 @@ function renderTransactions(txs) {
|
|||||||
row.addEventListener("click", () => {
|
row.addEventListener("click", () => {
|
||||||
const idx = parseInt(row.dataset.tx, 10);
|
const idx = parseInt(row.dataset.tx, 10);
|
||||||
const tx = loadedTxs[idx];
|
const tx = loadedTxs[idx];
|
||||||
const counterparty = tx.direction === "sent" ? tx.to : tx.from;
|
|
||||||
tx.fromEns = ensNameMap.get(tx.from) || null;
|
tx.fromEns = ensNameMap.get(tx.from) || null;
|
||||||
tx.toEns = ensNameMap.get(tx.to) || null;
|
tx.toEns = ensNameMap.get(tx.to) || null;
|
||||||
ctx.showTransactionDetail(tx);
|
ctx.showTransactionDetail(tx);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const {
|
|||||||
goBack,
|
goBack,
|
||||||
pushCurrentView,
|
pushCurrentView,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state, currentAddress, saveState } = require("../../shared/state");
|
const { state, saveState } = require("../../shared/state");
|
||||||
const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
|
const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
|
||||||
const { formatUsd, getPrice } = require("../../shared/prices");
|
const { formatUsd, getPrice } = require("../../shared/prices");
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const {
|
|||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
onViewLeave,
|
onViewLeave,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
const { state, saveState } = require("../../shared/state");
|
||||||
const { networkByChainId } = require("../../shared/networks");
|
const { networkByChainId } = require("../../shared/networks");
|
||||||
const {
|
const {
|
||||||
formatEther,
|
formatEther,
|
||||||
@@ -27,8 +27,7 @@ const { walletDefect } = require("../../shared/walletDefects");
|
|||||||
const { describeSigningFailure } = require("../../shared/approvalVerify");
|
const { describeSigningFailure } = require("../../shared/approvalVerify");
|
||||||
const txStatus = require("./txStatus");
|
const txStatus = require("./txStatus");
|
||||||
const uniswap = require("../../shared/uniswap");
|
const uniswap = require("../../shared/uniswap");
|
||||||
const runtime =
|
const { notify, runtimeApi, sendMessage } = require("../../shared/browserApi");
|
||||||
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
|
|
||||||
|
|
||||||
const erc20Iface = new Interface(ERC20_ABI);
|
const erc20Iface = new Interface(ERC20_ABI);
|
||||||
|
|
||||||
@@ -439,10 +438,20 @@ function showSignApproval(details) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function show(id) {
|
// Awaited by nobody: the popup entry point calls this and moves on. It
|
||||||
|
// therefore has to absorb its own failure, and a background that cannot
|
||||||
|
// describe the approval is the same outcome as an approval that is gone.
|
||||||
|
async function show(id) {
|
||||||
approvalId = id;
|
approvalId = id;
|
||||||
runtime.connect({ name: "approval:" + id });
|
approvalPort = runtimeApi().connect({ name: "approval:" + id });
|
||||||
runtime.sendMessage({ type: "AUTISTMASK_GET_APPROVAL", id }, (details) => {
|
|
||||||
|
let details = null;
|
||||||
|
try {
|
||||||
|
details = await sendMessage({ type: "AUTISTMASK_GET_APPROVAL", id });
|
||||||
|
} catch {
|
||||||
|
details = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!details) {
|
if (!details) {
|
||||||
window.close();
|
window.close();
|
||||||
return;
|
return;
|
||||||
@@ -461,15 +470,20 @@ function show(id) {
|
|||||||
details.isPhishingDomain,
|
details.isPhishingDomain,
|
||||||
);
|
);
|
||||||
$("approve-hostname").textContent = details.hostname;
|
$("approve-hostname").textContent = details.hostname;
|
||||||
$("approve-address").innerHTML = approvalAddressHtml(
|
$("approve-address").innerHTML = approvalAddressHtml(state.activeAddress);
|
||||||
state.activeAddress,
|
|
||||||
);
|
|
||||||
attachCopyHandlers("view-approve-site");
|
attachCopyHandlers("view-approve-site");
|
||||||
$("approve-remember").checked = state.rememberSiteChoice;
|
$("approve-remember").checked = state.rememberSiteChoice;
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let approvalId = null;
|
let approvalId = null;
|
||||||
|
// The port this approval was opened on. Closing this window disconnects it,
|
||||||
|
// and the background treats that disconnect as "closed without deciding" for a
|
||||||
|
// site connection — so the decision goes out on this same port and not as a
|
||||||
|
// one-off message. One channel is ordered: a message posted on it is delivered
|
||||||
|
// before its own disconnect, however immediately the close follows. Two
|
||||||
|
// channels were not, and the close won, reporting a user who approved as
|
||||||
|
// having refused.
|
||||||
|
let approvalPort = null;
|
||||||
let pendingTxDetails = null;
|
let pendingTxDetails = null;
|
||||||
// The exact objects shown to the user, kept so the popup signs what it
|
// The exact objects shown to the user, kept so the popup signs what it
|
||||||
// displayed rather than re-fetching or re-populating anything at approval
|
// displayed rather than re-fetching or re-populating anything at approval
|
||||||
@@ -537,7 +551,29 @@ function clearSignPassword() {
|
|||||||
hideError("approve-sign-error");
|
hideError("approve-sign-error");
|
||||||
}
|
}
|
||||||
|
|
||||||
function init(ctx) {
|
// Answer a site-connection approval and close. The decision goes out on the
|
||||||
|
// approval port — see approvalPort above for why — and carries no approval id,
|
||||||
|
// because the port name already names the approval the background will settle.
|
||||||
|
// The post is guarded because a throw must not cost the close: posting on a
|
||||||
|
// port whose background worker has been torn down throws, and the approval it
|
||||||
|
// would have settled died with that worker, so the only thing left to do is
|
||||||
|
// what the user asked for — go away.
|
||||||
|
function decideSite(approved) {
|
||||||
|
if (approvalPort) {
|
||||||
|
try {
|
||||||
|
approvalPort.postMessage({
|
||||||
|
type: "AUTISTMASK_APPROVAL_DECISION",
|
||||||
|
approved,
|
||||||
|
remember: $("approve-remember").checked,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Nothing to report it to; the window closes either way.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function init(_ctx) {
|
||||||
onViewLeave("approve-tx", clearTxPassword);
|
onViewLeave("approve-tx", clearTxPassword);
|
||||||
onViewLeave("approve-sign", clearSignPassword);
|
onViewLeave("approve-sign", clearSignPassword);
|
||||||
|
|
||||||
@@ -547,25 +583,11 @@ function init(ctx) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
$("btn-approve").addEventListener("click", () => {
|
$("btn-approve").addEventListener("click", () => {
|
||||||
const remember = $("approve-remember").checked;
|
decideSite(true);
|
||||||
runtime.sendMessage({
|
|
||||||
type: "AUTISTMASK_APPROVAL_RESPONSE",
|
|
||||||
id: approvalId,
|
|
||||||
approved: true,
|
|
||||||
remember,
|
|
||||||
});
|
|
||||||
window.close();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$("btn-reject").addEventListener("click", () => {
|
$("btn-reject").addEventListener("click", () => {
|
||||||
const remember = $("approve-remember").checked;
|
decideSite(false);
|
||||||
runtime.sendMessage({
|
|
||||||
type: "AUTISTMASK_APPROVAL_RESPONSE",
|
|
||||||
id: approvalId,
|
|
||||||
approved: false,
|
|
||||||
remember,
|
|
||||||
});
|
|
||||||
window.close();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$("btn-approve-tx").addEventListener("click", async () => {
|
$("btn-approve-tx").addEventListener("click", async () => {
|
||||||
@@ -648,7 +670,16 @@ function init(ctx) {
|
|||||||
decryptedSecret = null;
|
decryptedSecret = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime.sendMessage(payload, (response) => {
|
// A send that never reaches the background is reported to the user
|
||||||
|
// the same way a background that refused it is: describeSigningFailure
|
||||||
|
// turns a null response into the generic message below.
|
||||||
|
let response = null;
|
||||||
|
try {
|
||||||
|
response = await sendMessage(payload);
|
||||||
|
} catch {
|
||||||
|
response = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (response && response.txHash) {
|
if (response && response.txHash) {
|
||||||
txStatus.showWait(pendingTxDetails, response.txHash);
|
txStatus.showWait(pendingTxDetails, response.txHash);
|
||||||
return;
|
return;
|
||||||
@@ -667,10 +698,9 @@ function init(ctx) {
|
|||||||
txStatus.showError(pendingTxDetails, null, outcome.message);
|
txStatus.showError(pendingTxDetails, null, outcome.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
$("btn-reject-tx").addEventListener("click", () => {
|
$("btn-reject-tx").addEventListener("click", () => {
|
||||||
runtime.sendMessage({
|
notify({
|
||||||
type: "AUTISTMASK_TX_RESPONSE",
|
type: "AUTISTMASK_TX_RESPONSE",
|
||||||
id: approvalId,
|
id: approvalId,
|
||||||
approved: false,
|
approved: false,
|
||||||
@@ -764,7 +794,13 @@ function init(ctx) {
|
|||||||
decryptedSecret = null;
|
decryptedSecret = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime.sendMessage(payload, (response) => {
|
let response = null;
|
||||||
|
try {
|
||||||
|
response = await sendMessage(payload);
|
||||||
|
} catch {
|
||||||
|
response = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (response && response.signature) {
|
if (response && response.signature) {
|
||||||
window.close();
|
window.close();
|
||||||
return;
|
return;
|
||||||
@@ -780,10 +816,9 @@ function init(ctx) {
|
|||||||
showError("approve-sign-error", outcome.message);
|
showError("approve-sign-error", outcome.message);
|
||||||
if (outcome.retryable) setSignButtonBusy(false);
|
if (outcome.retryable) setSignButtonBusy(false);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
$("btn-reject-sign").addEventListener("click", () => {
|
$("btn-reject-sign").addEventListener("click", () => {
|
||||||
runtime.sendMessage({
|
notify({
|
||||||
type: "AUTISTMASK_SIGN_RESPONSE",
|
type: "AUTISTMASK_SIGN_RESPONSE",
|
||||||
id: approvalId,
|
id: approvalId,
|
||||||
approved: false,
|
approved: false,
|
||||||
|
|||||||
@@ -2,20 +2,12 @@
|
|||||||
// Shows transaction details, warnings, errors. On Sign & Send,
|
// Shows transaction details, warnings, errors. On Sign & Send,
|
||||||
// reads inline password, decrypts secret, signs and broadcasts.
|
// reads inline password, decrypts secret, signs and broadcasts.
|
||||||
|
|
||||||
const {
|
const { parseEther, parseUnits, formatEther, Contract } = require("ethers");
|
||||||
parseEther,
|
|
||||||
parseUnits,
|
|
||||||
formatEther,
|
|
||||||
formatUnits,
|
|
||||||
Contract,
|
|
||||||
} = require("ethers");
|
|
||||||
const {
|
const {
|
||||||
$,
|
$,
|
||||||
showError,
|
showError,
|
||||||
hideError,
|
hideError,
|
||||||
showView,
|
showView,
|
||||||
showFlash,
|
|
||||||
flashCopyFeedback,
|
|
||||||
addressTitle,
|
addressTitle,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
@@ -23,7 +15,7 @@ const {
|
|||||||
goBack,
|
goBack,
|
||||||
onViewLeave,
|
onViewLeave,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state, currentNetwork } = require("../../shared/state");
|
const { state } = require("../../shared/state");
|
||||||
const { getSignerForAddress } = require("../../shared/wallet");
|
const { getSignerForAddress } = require("../../shared/wallet");
|
||||||
const { decryptWithPassword } = require("../../shared/vault");
|
const { decryptWithPassword } = require("../../shared/vault");
|
||||||
const { formatUsd, getPrice } = require("../../shared/prices");
|
const { formatUsd, getPrice } = require("../../shared/prices");
|
||||||
@@ -399,7 +391,7 @@ function clearPassword() {
|
|||||||
hideError("confirm-tx-password-error");
|
hideError("confirm-tx-password-error");
|
||||||
}
|
}
|
||||||
|
|
||||||
function init(ctx) {
|
function init(_ctx) {
|
||||||
onViewLeave("confirm-tx", clearPassword);
|
onViewLeave("confirm-tx", clearPassword);
|
||||||
|
|
||||||
$("btn-confirm-send").addEventListener("click", async () => {
|
$("btn-confirm-send").addEventListener("click", async () => {
|
||||||
@@ -421,7 +413,7 @@ function init(ctx) {
|
|||||||
wallet.encryptedSecret,
|
wallet.encryptedSecret,
|
||||||
password,
|
password,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch {
|
||||||
showError(
|
showError(
|
||||||
"confirm-tx-password-error",
|
"confirm-tx-password-error",
|
||||||
"That password is incorrect. Please try again.",
|
"That password is incorrect. Please try again.",
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ function init(_ctx) {
|
|||||||
// Verify password against the wallet's encrypted data
|
// Verify password against the wallet's encrypted data
|
||||||
try {
|
try {
|
||||||
await decryptWithPassword(wallet.encryptedSecret, pw);
|
await decryptWithPassword(wallet.encryptedSecret, pw);
|
||||||
} catch (_e) {
|
} catch {
|
||||||
$("delete-wallet-flash").textContent =
|
$("delete-wallet-flash").textContent =
|
||||||
"That password is incorrect. Please try again.";
|
"That password is incorrect. Please try again.";
|
||||||
$("delete-wallet-flash").style.visibility = "visible";
|
$("delete-wallet-flash").style.visibility = "visible";
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ const {
|
|||||||
$,
|
$,
|
||||||
showView,
|
showView,
|
||||||
showFlash,
|
showFlash,
|
||||||
flashCopyFeedback,
|
|
||||||
balanceLinesForAddress,
|
balanceLinesForAddress,
|
||||||
isoDate,
|
isoDate,
|
||||||
timeAgo,
|
timeAgo,
|
||||||
@@ -15,6 +14,7 @@ const {
|
|||||||
pushCurrentView,
|
pushCurrentView,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state, saveState, currentAddress } = require("../../shared/state");
|
const { state, saveState, currentAddress } = require("../../shared/state");
|
||||||
|
const { notify } = require("../../shared/browserApi");
|
||||||
const {
|
const {
|
||||||
updateSendBalance,
|
updateSendBalance,
|
||||||
renderSendTokenSelect,
|
renderSendTokenSelect,
|
||||||
@@ -292,11 +292,7 @@ function render(ctx) {
|
|||||||
state.activeAddress = addr;
|
state.activeAddress = addr;
|
||||||
await saveState();
|
await saveState();
|
||||||
render(ctx);
|
render(ctx);
|
||||||
const runtime =
|
notify({ type: "AUTISTMASK_ACTIVE_CHANGED" });
|
||||||
typeof browser !== "undefined"
|
|
||||||
? browser.runtime
|
|
||||||
: chrome.runtime;
|
|
||||||
runtime.sendMessage({ type: "AUTISTMASK_ACTIVE_CHANGED" });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ function show() {
|
|||||||
attachCopyHandlers("view-receive");
|
attachCopyHandlers("view-receive");
|
||||||
}
|
}
|
||||||
|
|
||||||
function init(ctx) {
|
function init(_ctx) {
|
||||||
$("btn-receive-copy").addEventListener("click", () => {
|
$("btn-receive-copy").addEventListener("click", () => {
|
||||||
const addr = $("receive-address-block").dataset.full;
|
const addr = $("receive-address-block").dataset.full;
|
||||||
if (addr) {
|
if (addr) {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ const {
|
|||||||
$,
|
$,
|
||||||
showFlash,
|
showFlash,
|
||||||
addressTitle,
|
addressTitle,
|
||||||
escapeHtml,
|
|
||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
goBack,
|
goBack,
|
||||||
@@ -210,7 +209,7 @@ function init(_ctx) {
|
|||||||
}
|
}
|
||||||
resolvedTo = resolved;
|
resolvedTo = resolved;
|
||||||
ensName = to;
|
ensName = to;
|
||||||
} catch (e) {
|
} catch {
|
||||||
showFlash("Failed to resolve ENS name.");
|
showFlash("Failed to resolve ENS name.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ const {
|
|||||||
parseDustThresholdGwei,
|
parseDustThresholdGwei,
|
||||||
} = require("../dustThreshold");
|
} = require("../dustThreshold");
|
||||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||||
const { NETWORKS, SUPPORTED_CHAIN_IDS } = require("../../shared/networks");
|
|
||||||
const { onChainSwitch } = require("../../shared/chainSwitch");
|
const { onChainSwitch } = require("../../shared/chainSwitch");
|
||||||
const { log, debugFetch, setRuntimeDebug } = require("../../shared/log");
|
const { log, debugFetch, setRuntimeDebug } = require("../../shared/log");
|
||||||
const deleteWallet = require("./deleteWallet");
|
const deleteWallet = require("./deleteWallet");
|
||||||
@@ -29,8 +28,7 @@ const {
|
|||||||
GITEA_COMMIT_URL,
|
GITEA_COMMIT_URL,
|
||||||
} = require("../../shared/buildInfo");
|
} = require("../../shared/buildInfo");
|
||||||
|
|
||||||
const runtime =
|
const { notify } = require("../../shared/browserApi");
|
||||||
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
|
|
||||||
|
|
||||||
let versionClickCount = 0;
|
let versionClickCount = 0;
|
||||||
let versionClickTimer = null;
|
let versionClickTimer = null;
|
||||||
@@ -61,7 +59,7 @@ function renderSiteList(containerId, siteMap, stateKey) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
await saveState();
|
await saveState();
|
||||||
runtime.sendMessage({ type: "AUTISTMASK_REMOVE_SITE" });
|
notify({ type: "AUTISTMASK_REMOVE_SITE" });
|
||||||
renderSiteList(containerId, state[key], key);
|
renderSiteList(containerId, state[key], key);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -169,10 +167,7 @@ function renderWalletListSettings() {
|
|||||||
function show() {
|
function show() {
|
||||||
$("settings-rpc").value = state.rpcUrl;
|
$("settings-rpc").value = state.rpcUrl;
|
||||||
$("settings-blockscout").value = state.blockscoutUrl;
|
$("settings-blockscout").value = state.blockscoutUrl;
|
||||||
const networkSelect = $("settings-network");
|
$("settings-network").value = state.networkId;
|
||||||
if (networkSelect) {
|
|
||||||
networkSelect.value = state.networkId;
|
|
||||||
}
|
|
||||||
renderTrackedTokens();
|
renderTrackedTokens();
|
||||||
renderSiteLists();
|
renderSiteLists();
|
||||||
renderWalletListSettings();
|
renderWalletListSettings();
|
||||||
@@ -284,7 +279,6 @@ function init(ctx) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const networkSelect = $("settings-network");
|
const networkSelect = $("settings-network");
|
||||||
if (networkSelect) {
|
|
||||||
networkSelect.addEventListener("change", async () => {
|
networkSelect.addEventListener("change", async () => {
|
||||||
const newId = networkSelect.value;
|
const newId = networkSelect.value;
|
||||||
const net = await onChainSwitch(newId);
|
const net = await onChainSwitch(newId);
|
||||||
@@ -292,7 +286,6 @@ function init(ctx) {
|
|||||||
$("settings-blockscout").value = state.blockscoutUrl;
|
$("settings-blockscout").value = state.blockscoutUrl;
|
||||||
showFlash("Switched to " + net.name + ".");
|
showFlash("Switched to " + net.name + ".");
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
$("settings-show-zero-balances").checked = state.showZeroBalanceTokens;
|
$("settings-show-zero-balances").checked = state.showZeroBalanceTokens;
|
||||||
$("settings-show-zero-balances").addEventListener("change", async () => {
|
$("settings-show-zero-balances").addEventListener("change", async () => {
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ const makeBlockie = require("ethereum-blockies-base64");
|
|||||||
const { log, debugFetch } = require("../../shared/log");
|
const { log, debugFetch } = require("../../shared/log");
|
||||||
const { decodeCalldata } = require("./approval");
|
const { decodeCalldata } = require("./approval");
|
||||||
|
|
||||||
let ctx;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine a human-readable transaction type string from tx fields.
|
* Determine a human-readable transaction type string from tx fields.
|
||||||
*/
|
*/
|
||||||
@@ -166,7 +164,7 @@ function render() {
|
|||||||
if (el) el.classList.add("hidden");
|
if (el) el.classList.add("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
loadFullTxDetails(tx.hash, tx.to, tx.isContractCall);
|
loadFullTxDetails(tx.hash, tx.to);
|
||||||
|
|
||||||
const isoStr = isoDate(tx.timestamp);
|
const isoStr = isoDate(tx.timestamp);
|
||||||
$("tx-detail-time").innerHTML =
|
$("tx-detail-time").innerHTML =
|
||||||
@@ -274,7 +272,7 @@ function populateOnChainDetails(txData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadFullTxDetails(txHash, toAddress, isContractCall) {
|
async function loadFullTxDetails(txHash, toAddress) {
|
||||||
const section = $("tx-detail-calldata-section");
|
const section = $("tx-detail-calldata-section");
|
||||||
const actionEl = $("tx-detail-calldata-action");
|
const actionEl = $("tx-detail-calldata-action");
|
||||||
const detailsEl = $("tx-detail-calldata-details");
|
const detailsEl = $("tx-detail-calldata-details");
|
||||||
@@ -349,8 +347,9 @@ async function loadFullTxDetails(txHash, toAddress, isContractCall) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The ctx this view is initialized with is unused: this module is the leaf of
|
||||||
|
// the navigation, and the other views reach it through their own ctx.
|
||||||
function init(_ctx) {
|
function init(_ctx) {
|
||||||
ctx = _ctx;
|
|
||||||
$("btn-tx-back").addEventListener("click", () => {
|
$("btn-tx-back").addEventListener("click", () => {
|
||||||
goBack();
|
goBack();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const {
|
|||||||
clearViewStack,
|
clearViewStack,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
|
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
|
||||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
const { state, currentNetwork } = require("../../shared/state");
|
||||||
const { getProvider } = require("../../shared/balances");
|
const { getProvider } = require("../../shared/balances");
|
||||||
const { log } = require("../../shared/log");
|
const { log } = require("../../shared/log");
|
||||||
|
|
||||||
@@ -229,10 +229,6 @@ function tokenLabel(address) {
|
|||||||
return t ? t.symbol : null;
|
return t ? t.symbol : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function etherscanTokenLink(address) {
|
|
||||||
return `${currentNetwork().explorerUrl}/token/${address}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodedDetailsHtml(decoded) {
|
function decodedDetailsHtml(decoded) {
|
||||||
if (!decoded || !decoded.details) return "";
|
if (!decoded || !decoded.details) return "";
|
||||||
let html = `<div class="border border-border border-dashed p-2 mb-3">`;
|
let html = `<div class="border border-border border-dashed p-2 mb-3">`;
|
||||||
|
|||||||
@@ -17,23 +17,31 @@
|
|||||||
// run finished and the alarm fires one run-duration earlier than that. Every
|
// run finished and the alarm fires one run-duration earlier than that. Every
|
||||||
// guard must therefore either be strictly shorter than the period it gates or
|
// guard must therefore either be strictly shorter than the period it gates or
|
||||||
// be bypassed on the scheduled tick — see backgroundRefresh() in
|
// be bypassed on the scheduled tick — see backgroundRefresh() in
|
||||||
// src/background/index.js and updatePhishingList() in shared/phishingDomains.js.
|
// src/background/index.js.
|
||||||
|
|
||||||
|
const { alarmsApi } = require("./browserApi");
|
||||||
|
|
||||||
const BALANCE_REFRESH_ALARM = "autistmask-balance-refresh";
|
const BALANCE_REFRESH_ALARM = "autistmask-balance-refresh";
|
||||||
const PHISHING_REFRESH_ALARM = "autistmask-phishing-refresh";
|
|
||||||
|
// Alarms this extension used to create and no longer has a handler for. A
|
||||||
|
// browser keeps an alarm until something clears it, so a job that is deleted
|
||||||
|
// from the code goes on waking the service worker on its old schedule forever,
|
||||||
|
// on every install that ever ran the version which created it. Removing the job
|
||||||
|
// means removing the alarm, so retired names are listed here and cleared on
|
||||||
|
// every start until the installs that carry them are long gone.
|
||||||
|
const OBSOLETE_ALARMS = [
|
||||||
|
// The 24-hour phishing blocklist refresh, retired when the runtime fetch
|
||||||
|
// was removed and the list became purely build-time vendored.
|
||||||
|
"autistmask-phishing-refresh",
|
||||||
|
];
|
||||||
|
|
||||||
const MIN_ALARM_PERIOD_MINUTES = 1;
|
const MIN_ALARM_PERIOD_MINUTES = 1;
|
||||||
const BALANCE_REFRESH_PERIOD_MINUTES = 1;
|
const BALANCE_REFRESH_PERIOD_MINUTES = 1;
|
||||||
const PHISHING_REFRESH_PERIOD_MINUTES = 24 * 60;
|
|
||||||
|
|
||||||
// Resolved on use rather than captured at module load: the worker is torn
|
// alarmsApi() resolves on use rather than at module load: the worker is torn
|
||||||
// down and re-evaluated repeatedly, and tests install a stub after requiring
|
// down and re-evaluated repeatedly, and tests install a stub after requiring
|
||||||
// the module.
|
// this module. It returns null where the API is absent, which is why every
|
||||||
function alarmsApi() {
|
// entry point below degrades instead of throwing.
|
||||||
if (typeof browser !== "undefined" && browser.alarms) return browser.alarms;
|
|
||||||
if (typeof chrome !== "undefined" && chrome.alarms) return chrome.alarms;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an alarm unless one with the requested period already exists.
|
* Create an alarm unless one with the requested period already exists.
|
||||||
@@ -67,22 +75,34 @@ async function ensureAlarm(name, periodInMinutes) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure both recurring background jobs are scheduled. Safe to call on every
|
* Clear every alarm this extension no longer handles.
|
||||||
* worker start, on onInstalled and on onStartup.
|
|
||||||
*
|
*
|
||||||
* @returns {Promise<{balance: boolean, phishing: boolean}>} which alarms this
|
* @returns {Promise<string[]>} the retired alarms this call actually cleared.
|
||||||
* call had to create.
|
*/
|
||||||
|
async function clearObsoleteAlarms() {
|
||||||
|
const api = alarmsApi();
|
||||||
|
if (!api || !api.clear) return [];
|
||||||
|
const cleared = [];
|
||||||
|
for (const name of OBSOLETE_ALARMS) {
|
||||||
|
if (await api.clear(name)) cleared.push(name);
|
||||||
|
}
|
||||||
|
return cleared;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the recurring background jobs are scheduled, and that retired ones are
|
||||||
|
* not. Safe to call on every worker start, on onInstalled and on onStartup.
|
||||||
|
*
|
||||||
|
* @returns {Promise<{balance: boolean, cleared: string[]}>} which alarms this
|
||||||
|
* call had to create, and which retired ones it removed.
|
||||||
*/
|
*/
|
||||||
async function ensureRecurringAlarms() {
|
async function ensureRecurringAlarms() {
|
||||||
const balance = await ensureAlarm(
|
const balance = await ensureAlarm(
|
||||||
BALANCE_REFRESH_ALARM,
|
BALANCE_REFRESH_ALARM,
|
||||||
BALANCE_REFRESH_PERIOD_MINUTES,
|
BALANCE_REFRESH_PERIOD_MINUTES,
|
||||||
);
|
);
|
||||||
const phishing = await ensureAlarm(
|
const cleared = await clearObsoleteAlarms();
|
||||||
PHISHING_REFRESH_ALARM,
|
return { balance, cleared };
|
||||||
PHISHING_REFRESH_PERIOD_MINUTES,
|
|
||||||
);
|
|
||||||
return { balance, phishing };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,10 +124,10 @@ function registerAlarmHandlers(handlers) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
BALANCE_REFRESH_ALARM,
|
BALANCE_REFRESH_ALARM,
|
||||||
PHISHING_REFRESH_ALARM,
|
OBSOLETE_ALARMS,
|
||||||
MIN_ALARM_PERIOD_MINUTES,
|
MIN_ALARM_PERIOD_MINUTES,
|
||||||
BALANCE_REFRESH_PERIOD_MINUTES,
|
BALANCE_REFRESH_PERIOD_MINUTES,
|
||||||
PHISHING_REFRESH_PERIOD_MINUTES,
|
clearObsoleteAlarms,
|
||||||
ensureAlarm,
|
ensureAlarm,
|
||||||
ensureRecurringAlarms,
|
ensureRecurringAlarms,
|
||||||
registerAlarmHandlers,
|
registerAlarmHandlers,
|
||||||
|
|||||||
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,
|
// POPUP ONLY. localStorage does not exist in the Chrome MV3 service worker,
|
||||||
// so this module must not be pulled into src/background/. Anything the
|
// so this module must not be pulled into src/background/. Anything the
|
||||||
// background context needs to cache goes in extension storage instead (see
|
// background context needs to cache goes in extension storage instead.
|
||||||
// shared/phishingDomains.js).
|
|
||||||
|
|
||||||
const { getProvider } = require("./balances");
|
const { getProvider } = require("./balances");
|
||||||
const { log } = require("./log");
|
const { log } = require("./log");
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,165 +1,109 @@
|
|||||||
// Domain-based phishing detection using a vendored blocklist with delta updates.
|
// Domain-based phishing detection against a blocklist vendored at build time.
|
||||||
//
|
//
|
||||||
// A community-maintained phishing domain blocklist is vendored in
|
// The list is produced by script/vendor-blocklist from a hash-pinned upstream
|
||||||
// phishingBlocklist.json and bundled at build time. At runtime, we fetch
|
// commit, committed as phishingBlocklist.json, and bundled. There is no runtime
|
||||||
// the live list periodically and keep only the delta (new entries not in
|
// fetch: the extension asks nobody anything to answer this question, so no third
|
||||||
// the vendored list) in memory. This keeps runtime memory usage small.
|
// party learns which sites a user connects to, and no third party decides what
|
||||||
|
// this wallet warns about. The cost is staleness — the shipped list is exactly
|
||||||
|
// as fresh as the last vendoring run that was released — and the refresh path is
|
||||||
|
// re-running that script and shipping the diff.
|
||||||
//
|
//
|
||||||
// The domain-checker checks the in-memory delta first (fresh/recent scam
|
// The artifact holds digests, not domains: sha256 truncated to 64 bits, one
|
||||||
// sites), then falls back to the vendored list.
|
// entry per 16 hex characters, concatenated in sorted order into a single
|
||||||
|
// string (see domainHash.js). Three things follow from that shape, and all
|
||||||
|
// three are the reason for it:
|
||||||
//
|
//
|
||||||
// If the delta and its fetch timestamp fit in 256 KiB they are persisted to
|
// - the extension ships no plaintext list of anyone's domain names, which is
|
||||||
// extension storage, so they survive termination of the MV3 service worker.
|
// what makes a blocklist assembled elsewhere shippable here at all.
|
||||||
// Extension storage, not localStorage: localStorage does not exist in a
|
// - a lookup is a binary search over that string. Nothing is built at module
|
||||||
// service worker, so the previous persistence never ran on Chrome at all.
|
// load, which matters because the MV3 service worker is torn down when idle
|
||||||
// The stored timestamps are what keep a restarted worker from re-fetching on
|
// and re-evaluates this file on every wake.
|
||||||
// every wake while still noticing an overdue update. Those guards apply to the
|
// - the file is 1.7 MB rather than 8.7 MB.
|
||||||
// startup path only; the 24-hour alarm tick bypasses them, or it would veto
|
//
|
||||||
// its own refresh — see updatePhishingList().
|
// Nothing here is async: callers answer an approval prompt with the result.
|
||||||
|
|
||||||
const vendoredConfig = require("./phishingBlocklist.json");
|
const vendored = require("./phishingBlocklist.json");
|
||||||
|
const { HASH_ALGORITHM, HASH_HEX_CHARS, hashDomain } = require("./domainHash");
|
||||||
|
|
||||||
const BLOCKLIST_URL =
|
// The artifact is generated, so a shape it does not have is a build fault, not
|
||||||
"https://raw.githubusercontent.com/MetaMask/eth-phishing-detect/main/src/config.json";
|
// a runtime condition. It is checked anyway, and loudly, because every way of
|
||||||
|
// getting it wrong — a stale format, a truncated file, a different digest —
|
||||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
// produces a blocklist that matches nothing at all while looking perfectly
|
||||||
|
// healthy. A phishing check that silently answers "no" to everything is the one
|
||||||
// Floor on how often an unscheduled path may hit the network. The worker is
|
// failure this module must not have.
|
||||||
// revived every ~30 seconds while the browser is busy, and every revival runs
|
function checkArtifact(a) {
|
||||||
// the startup path; without a persisted record of the last attempt, any state
|
const bad = (why) =>
|
||||||
// that leaves lastFetchTime unset — a fetch that failed, or a delta too large
|
new Error(
|
||||||
// to store — would download the full list on every single wake.
|
"phishingBlocklist.json " +
|
||||||
const MIN_FETCH_ATTEMPT_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
why +
|
||||||
|
". It is generated by script/vendor-blocklist; re-run that " +
|
||||||
const DELTA_STORAGE_KEY = "phishing-delta";
|
"rather than editing it.",
|
||||||
const MAX_DELTA_BYTES = 256 * 1024; // 256 KiB
|
|
||||||
|
|
||||||
// Vendored set — built once from the bundled JSON.
|
|
||||||
const vendoredBlacklist = new Set(
|
|
||||||
(vendoredConfig.blacklist || []).map((d) => d.toLowerCase()),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Delta set — only entries from live list that are NOT in vendored.
|
if (!a || typeof a !== "object") throw bad("is not an object");
|
||||||
let deltaBlacklist = new Set();
|
if (a.algorithm !== HASH_ALGORITHM) {
|
||||||
let lastFetchTime = 0;
|
throw bad(
|
||||||
let lastAttemptTime = 0;
|
"declares algorithm " +
|
||||||
let fetchPromise = null;
|
JSON.stringify(a.algorithm) +
|
||||||
let loadPromise = null;
|
", but this build hashes with " +
|
||||||
|
HASH_ALGORITHM,
|
||||||
// Resolved on use rather than captured at module load, so a test can install
|
|
||||||
// a stub after requiring the module and so the popup — which has no reason to
|
|
||||||
// touch the delta — does not fail to load where the API is absent.
|
|
||||||
function storageApi() {
|
|
||||||
if (typeof browser !== "undefined" && browser.storage) {
|
|
||||||
return browser.storage.local;
|
|
||||||
}
|
|
||||||
if (typeof chrome !== "undefined" && chrome.storage) {
|
|
||||||
return chrome.storage.local;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitise a timestamp read back from storage.
|
|
||||||
*
|
|
||||||
* A value in the future is permanent poison: every guard here measures elapsed
|
|
||||||
* time as `Date.now() - stamp` and tests only the lower bound, so a stamp a
|
|
||||||
* year ahead suppresses updates for a year with no path that ever clears it.
|
|
||||||
* Clock skew and a restored profile backup both produce one. Since these
|
|
||||||
* timestamps only ever gate work, discarding an impossible one is safe: it
|
|
||||||
* costs at most a single extra fetch and restores a sane value immediately.
|
|
||||||
*
|
|
||||||
* @param {unknown} value
|
|
||||||
* @returns {number} the timestamp, or 0 if it is unusable.
|
|
||||||
*/
|
|
||||||
function sanitizeTimestamp(value) {
|
|
||||||
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
|
|
||||||
if (value <= 0 || value > Date.now()) return 0;
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load the persisted delta and its timestamps from extension storage.
|
|
||||||
* Runs once per worker lifetime; every entry point funnels through
|
|
||||||
* ensureDeltaLoaded() so a wake from termination restores state exactly once.
|
|
||||||
*
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
async function loadDeltaFromStorage() {
|
|
||||||
const storage = storageApi();
|
|
||||||
if (!storage) return;
|
|
||||||
try {
|
|
||||||
const result = await storage.get(DELTA_STORAGE_KEY);
|
|
||||||
const data = result && result[DELTA_STORAGE_KEY];
|
|
||||||
if (!data) return;
|
|
||||||
if (Array.isArray(data.blacklist)) {
|
|
||||||
deltaBlacklist = new Set(
|
|
||||||
data.blacklist.map((d) => d.toLowerCase()),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
lastFetchTime = sanitizeTimestamp(data.lastFetchTime);
|
if (a.hashHexChars !== HASH_HEX_CHARS) {
|
||||||
lastAttemptTime = sanitizeTimestamp(data.lastAttemptTime);
|
throw bad(
|
||||||
} catch {
|
"declares " +
|
||||||
// Storage unavailable or corrupt — start empty and re-fetch.
|
JSON.stringify(a.hashHexChars) +
|
||||||
}
|
" hex characters per entry, but this build produces " +
|
||||||
}
|
HASH_HEX_CHARS,
|
||||||
|
|
||||||
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 (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();
|
checkArtifact(vendored);
|
||||||
return saveDeltaToStorage();
|
|
||||||
|
const HASHES = vendored.hashes;
|
||||||
|
const COUNT = vendored.count;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is this digest one of the vendored entries?
|
||||||
|
*
|
||||||
|
* Binary search over fixed-width records. The digests are lowercase hex of one
|
||||||
|
* width, so lexicographic order is numeric order and the artifact is written
|
||||||
|
* sorted; tests assert that ordering against the committed file, because an
|
||||||
|
* unsorted artifact would fail lookups silently rather than loudly.
|
||||||
|
*
|
||||||
|
* @param {string} hash
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function hashListed(hash) {
|
||||||
|
let lo = 0;
|
||||||
|
let hi = COUNT - 1;
|
||||||
|
while (lo <= hi) {
|
||||||
|
const mid = (lo + hi) >> 1;
|
||||||
|
const at = HASHES.slice(
|
||||||
|
mid * HASH_HEX_CHARS,
|
||||||
|
(mid + 1) * HASH_HEX_CHARS,
|
||||||
|
);
|
||||||
|
if (at === hash) return true;
|
||||||
|
if (at < hash) lo = mid + 1;
|
||||||
|
else hi = mid - 1;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -182,161 +126,33 @@ function hostnameVariants(hostname) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a hostname is on the phishing blocklist.
|
* Check if a hostname is on the phishing blocklist.
|
||||||
* Checks delta first (fresh/recent scam sites), then vendored list.
|
|
||||||
*
|
|
||||||
* Synchronous by design — callers answer an approval prompt with it. On a
|
|
||||||
* worker that has just woken, the persisted delta may still be loading; the
|
|
||||||
* vendored list, which is bundled and always present, carries the check until
|
|
||||||
* it lands.
|
|
||||||
*
|
*
|
||||||
* @param {string} hostname - The hostname to check.
|
* @param {string} hostname - The hostname to check.
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
function isPhishingDomain(hostname) {
|
function isPhishingDomain(hostname) {
|
||||||
if (!hostname) return false;
|
if (!hostname) return false;
|
||||||
const variants = hostnameVariants(hostname);
|
for (const variant of hostnameVariants(hostname)) {
|
||||||
|
if (hashListed(hashDomain(variant))) return true;
|
||||||
// Check delta blacklist first (fresh/recent scam sites), then vendored
|
|
||||||
for (const v of variants) {
|
|
||||||
if (deltaBlacklist.has(v) || vendoredBlacklist.has(v)) return true;
|
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch the latest blocklist and compute delta against vendored data.
|
* Return the blocklist size for diagnostics.
|
||||||
* De-duplicates concurrent fetches. Results are cached for CACHE_TTL_MS,
|
|
||||||
* counted from the persisted timestamp so the cache outlives the worker.
|
|
||||||
*
|
|
||||||
* `force` is what makes the 24-hour alarm actually refresh every 24 hours.
|
|
||||||
* The alarm fires one period after the previous alarm, but lastFetchTime is
|
|
||||||
* stamped when that fetch *completed*, so an unforced tick lands one fetch
|
|
||||||
* latency inside its own TTL, skips, and turns the real cadence into 48 hours.
|
|
||||||
* Shortening the TTL instead would not fix it: the worker wakes every ~30
|
|
||||||
* seconds and the startup path re-checks the TTL each time, so a shortened TTL
|
|
||||||
* simply becomes the real cadence. The TTL is there to stop redundant fetches
|
|
||||||
* on wake, and the scheduled tick is not redundant, so it bypasses it.
|
|
||||||
*
|
|
||||||
* @param {{force?: boolean}} [opts] force: fetch unless one is already in
|
|
||||||
* flight, ignoring both the freshness and the retry guard. For the scheduled
|
|
||||||
* alarm tick only.
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
async function updatePhishingList({ force = false } = {}) {
|
|
||||||
// A worker that has just been revived knows nothing until the persisted
|
|
||||||
// record is back in memory; without this the freshness check below would
|
|
||||||
// always see 0 and re-fetch on every wake.
|
|
||||||
await ensureDeltaLoaded();
|
|
||||||
|
|
||||||
if (!force) {
|
|
||||||
const now = Date.now();
|
|
||||||
// Skip if recently fetched.
|
|
||||||
if (lastFetchTime > 0 && now - lastFetchTime < CACHE_TTL_MS) return;
|
|
||||||
// Skip if the network was contacted recently and the result was not
|
|
||||||
// usable — a failed fetch or an oversized delta leaves lastFetchTime
|
|
||||||
// unset, and without this every wake would retry.
|
|
||||||
if (
|
|
||||||
lastAttemptTime > 0 &&
|
|
||||||
now - lastAttemptTime < MIN_FETCH_ATTEMPT_INTERVAL_MS
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// De-duplicate concurrent calls
|
|
||||||
if (fetchPromise) return fetchPromise;
|
|
||||||
|
|
||||||
fetchPromise = (async () => {
|
|
||||||
lastAttemptTime = Date.now();
|
|
||||||
try {
|
|
||||||
const resp = await fetch(BLOCKLIST_URL);
|
|
||||||
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
|
||||||
const config = await resp.json();
|
|
||||||
await loadConfig(config);
|
|
||||||
} catch {
|
|
||||||
// Silently fail — vendored list still provides coverage. Persist
|
|
||||||
// the attempt so a persistently failing fetch is retried on the
|
|
||||||
// schedule rather than on every wake.
|
|
||||||
await saveDeltaToStorage();
|
|
||||||
} finally {
|
|
||||||
fetchPromise = null;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return fetchPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restore persisted state and fetch if the list is overdue.
|
|
||||||
*
|
|
||||||
* Called from the background script every time it starts — a fresh install,
|
|
||||||
* a browser start, and every revival of a terminated service worker all land
|
|
||||||
* here. The recurring 24-hour schedule itself is an alarm (see
|
|
||||||
* shared/alarms.js), not a timer, because timers die with the worker.
|
|
||||||
*
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
async function initPhishingList() {
|
|
||||||
await ensureDeltaLoaded();
|
|
||||||
return updatePhishingList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The 24-hour alarm tick. Separate from initPhishingList() because this is the
|
|
||||||
* scheduled refresh and must not be vetoed by the guards that exist to keep
|
|
||||||
* the unscheduled startup path off the network.
|
|
||||||
*
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
async function refreshPhishingListOnSchedule() {
|
|
||||||
return updatePhishingList({ force: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the total blocklist size (vendored + delta) for diagnostics.
|
|
||||||
*
|
*
|
||||||
* @returns {number}
|
* @returns {number}
|
||||||
*/
|
*/
|
||||||
function getBlocklistSize() {
|
function getBlocklistSize() {
|
||||||
return vendoredBlacklist.size + deltaBlacklist.size;
|
return COUNT;
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the delta blocklist size for diagnostics.
|
|
||||||
*
|
|
||||||
* @returns {number}
|
|
||||||
*/
|
|
||||||
function getDeltaSize() {
|
|
||||||
return deltaBlacklist.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset internal state (for testing).
|
|
||||||
*/
|
|
||||||
function _reset() {
|
|
||||||
deltaBlacklist = new Set();
|
|
||||||
lastFetchTime = 0;
|
|
||||||
lastAttemptTime = 0;
|
|
||||||
fetchPromise = null;
|
|
||||||
loadPromise = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
isPhishingDomain,
|
isPhishingDomain,
|
||||||
updatePhishingList,
|
|
||||||
refreshPhishingListOnSchedule,
|
|
||||||
initPhishingList,
|
|
||||||
loadDeltaFromStorage,
|
|
||||||
loadConfig,
|
|
||||||
CACHE_TTL_MS,
|
|
||||||
MIN_FETCH_ATTEMPT_INTERVAL_MS,
|
|
||||||
DELTA_STORAGE_KEY,
|
|
||||||
MAX_DELTA_BYTES,
|
|
||||||
getBlocklistSize,
|
getBlocklistSize,
|
||||||
getDeltaSize,
|
|
||||||
hostnameVariants,
|
hostnameVariants,
|
||||||
_reset,
|
// Exposed for testing only: the ends of the search range are where an
|
||||||
// Exposed for testing only
|
// off-by-one hides, and reaching them through isPhishingDomain() would mean
|
||||||
_getVendoredBlacklistSize: () => vendoredBlacklist.size,
|
// knowing which domain hashes to the first or last entry.
|
||||||
_getDeltaBlacklist: () => deltaBlacklist,
|
_hashListed: hashListed,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ async function refreshPrices() {
|
|||||||
const fetched = await getTopTokenPrices(25);
|
const fetched = await getTopTokenPrices(25);
|
||||||
Object.assign(prices, fetched);
|
Object.assign(prices, fetched);
|
||||||
lastFetchedAt = now;
|
lastFetchedAt = now;
|
||||||
} catch (e) {
|
} catch {
|
||||||
// prices stay stale on error
|
// prices stay stale on error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,7 @@ const { networkById } = require("./networks");
|
|||||||
// Dependency-free constant module; safe to pull into a background bundle.
|
// Dependency-free constant module; safe to pull into a background bundle.
|
||||||
const { RESTORABLE_VIEWS } = require("../popup/restorableViews");
|
const { RESTORABLE_VIEWS } = require("../popup/restorableViews");
|
||||||
|
|
||||||
const storageApi =
|
const { storageGet, storageSet } = require("./browserApi");
|
||||||
typeof browser !== "undefined"
|
|
||||||
? browser.storage.local
|
|
||||||
: chrome.storage.local;
|
|
||||||
|
|
||||||
const DEFAULT_STATE = {
|
const DEFAULT_STATE = {
|
||||||
hasWallet: false,
|
hasWallet: false,
|
||||||
@@ -114,11 +111,11 @@ async function saveState() {
|
|||||||
viewData: state.viewData,
|
viewData: state.viewData,
|
||||||
viewStack: state.viewStack,
|
viewStack: state.viewStack,
|
||||||
};
|
};
|
||||||
await storageApi.set({ autistmask: persisted });
|
await storageSet({ autistmask: persisted });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadState() {
|
async function loadState() {
|
||||||
const result = await storageApi.get("autistmask");
|
const result = await storageGet("autistmask");
|
||||||
if (result.autistmask) {
|
if (result.autistmask) {
|
||||||
const saved = result.autistmask;
|
const saved = result.autistmask;
|
||||||
state.wallets = saved.wallets || [];
|
state.wallets = saved.wallets || [];
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ function toFixedPoint(value) {
|
|||||||
if (text === "") return null;
|
if (text === "") return null;
|
||||||
try {
|
try {
|
||||||
return parseUnits(text, SCALE_DECIMALS);
|
return parseUnits(text, SCALE_DECIMALS);
|
||||||
} catch (e) {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,11 @@ function decodeV2SwapExactIn(input) {
|
|||||||
// Decode V2_SWAP_EXACT_OUT (command 0x09) input bytes.
|
// Decode V2_SWAP_EXACT_OUT (command 0x09) input bytes.
|
||||||
// ABI: (address recipient, uint256 amountOut, uint256 amountInMax,
|
// ABI: (address recipient, uint256 amountOut, uint256 amountInMax,
|
||||||
// address[] path, bool payerIsUser)
|
// address[] path, bool payerIsUser)
|
||||||
|
//
|
||||||
|
// Nothing calls this: decode() has no 0x09 arm, so a V2 exact-out swap gets
|
||||||
|
// its command name and no token or amount detail. Kept for the fix, which is
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/283.
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
function decodeV2SwapExactOut(input) {
|
function decodeV2SwapExactOut(input) {
|
||||||
try {
|
try {
|
||||||
const d = coder.decode(
|
const d = coder.decode(
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ async function cryptoBackend() {
|
|||||||
try {
|
try {
|
||||||
await WebAssembly.compile(EMPTY_WASM_MODULE);
|
await WebAssembly.compile(EMPTY_WASM_MODULE);
|
||||||
return "wasm";
|
return "wasm";
|
||||||
} catch (_) {
|
} catch {
|
||||||
return "asmjs";
|
return "asmjs";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// Wallet and address deletion state transitions, kept out of the views so the
|
// Wallet and address deletion state transitions, kept out of the views so the
|
||||||
// selection and broadcast rules are testable without a DOM.
|
// selection and broadcast rules are testable without a DOM.
|
||||||
|
|
||||||
|
const { notify } = require("./browserApi");
|
||||||
|
|
||||||
// Two records of the same address can be stored in different cases, so
|
// Two records of the same address can be stored in different cases, so
|
||||||
// address equality is never a literal string comparison.
|
// address equality is never a literal string comparison.
|
||||||
function sameAddress(a, b) {
|
function sameAddress(a, b) {
|
||||||
@@ -144,9 +146,7 @@ function removeAddressFromState(state, walletIdx, addrIdx) {
|
|||||||
// accountsChanged to connected sites. Same call shape as the address
|
// accountsChanged to connected sites. Same call shape as the address
|
||||||
// switch in the home view.
|
// switch in the home view.
|
||||||
function broadcastActiveChanged() {
|
function broadcastActiveChanged() {
|
||||||
const runtime =
|
notify({ type: "AUTISTMASK_ACTIVE_CHANGED" });
|
||||||
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
|
|
||||||
runtime.sendMessage({ type: "AUTISTMASK_ACTIVE_CHANGED" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -80,17 +80,12 @@ describe("alarms module", () => {
|
|||||||
delete global.chrome;
|
delete global.chrome;
|
||||||
});
|
});
|
||||||
|
|
||||||
test("ensureRecurringAlarms schedules both recurring jobs", async () => {
|
test("ensureRecurringAlarms schedules the recurring job", async () => {
|
||||||
const created = await alarmsMod.ensureRecurringAlarms();
|
const created = await alarmsMod.ensureRecurringAlarms();
|
||||||
expect(created).toEqual({ balance: true, phishing: true });
|
expect(created).toEqual({ balance: true, cleared: [] });
|
||||||
|
|
||||||
const names = alarmsStub.created.map((c) => c.name).sort();
|
const names = alarmsStub.created.map((c) => c.name);
|
||||||
expect(names).toEqual(
|
expect(names).toEqual([alarmsMod.BALANCE_REFRESH_ALARM]);
|
||||||
[
|
|
||||||
alarmsMod.BALANCE_REFRESH_ALARM,
|
|
||||||
alarmsMod.PHISHING_REFRESH_ALARM,
|
|
||||||
].sort(),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the balance refresh keeps its 60-second cadence", async () => {
|
test("the balance refresh keeps its 60-second cadence", async () => {
|
||||||
@@ -99,12 +94,35 @@ describe("alarms module", () => {
|
|||||||
expect(balance.periodInMinutes).toBe(1);
|
expect(balance.periodInMinutes).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the phishing refresh keeps its 24-hour cadence", async () => {
|
test("a retired job's alarm is cleared, not left running", async () => {
|
||||||
|
// The browser holds an alarm until something clears it. Deleting the
|
||||||
|
// job from the code is not enough: on every install that ever ran the
|
||||||
|
// version which created it, the alarm goes on waking the service
|
||||||
|
// worker on its old schedule with nothing to deliver it to.
|
||||||
|
for (const name of alarmsMod.OBSOLETE_ALARMS) {
|
||||||
|
alarmsStub.create(name, { periodInMinutes: 24 * 60 });
|
||||||
|
}
|
||||||
|
expect(alarmsMod.OBSOLETE_ALARMS.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const result = await alarmsMod.ensureRecurringAlarms();
|
||||||
|
expect(result.cleared).toEqual(alarmsMod.OBSOLETE_ALARMS);
|
||||||
|
for (const name of alarmsMod.OBSOLETE_ALARMS) {
|
||||||
|
expect(alarmsStub.alarms.get(name)).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clearing a retired alarm is not re-reported once it is gone", async () => {
|
||||||
await alarmsMod.ensureRecurringAlarms();
|
await alarmsMod.ensureRecurringAlarms();
|
||||||
const phishing = alarmsStub.alarms.get(
|
const again = await alarmsMod.ensureRecurringAlarms();
|
||||||
alarmsMod.PHISHING_REFRESH_ALARM,
|
expect(again.cleared).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no retired name is also a live one", async () => {
|
||||||
|
// A name in both lists would be created and then cleared on every
|
||||||
|
// start, so the job it schedules would never fire.
|
||||||
|
expect(alarmsMod.OBSOLETE_ALARMS).not.toContain(
|
||||||
|
alarmsMod.BALANCE_REFRESH_ALARM,
|
||||||
);
|
);
|
||||||
expect(phishing.periodInMinutes).toBe(24 * 60);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("no period is below the browser-enforced minimum", async () => {
|
test("no period is below the browser-enforced minimum", async () => {
|
||||||
@@ -122,14 +140,14 @@ describe("alarms module", () => {
|
|||||||
|
|
||||||
test("a revived worker does not reset an existing alarm's schedule", async () => {
|
test("a revived worker does not reset an existing alarm's schedule", async () => {
|
||||||
await alarmsMod.ensureRecurringAlarms();
|
await alarmsMod.ensureRecurringAlarms();
|
||||||
expect(alarmsStub.create).toHaveBeenCalledTimes(2);
|
expect(alarmsStub.create).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
// Every wake re-runs the startup path. Re-creating an alarm restarts
|
// Every wake re-runs the startup path. Re-creating an alarm restarts
|
||||||
// its period, so a busy extension would push the next fire out
|
// its period, so a busy extension would push the next fire out
|
||||||
// forever and the job would never run.
|
// forever and the job would never run.
|
||||||
const again = await alarmsMod.ensureRecurringAlarms();
|
const again = await alarmsMod.ensureRecurringAlarms();
|
||||||
expect(again).toEqual({ balance: false, phishing: false });
|
expect(again).toEqual({ balance: false, cleared: [] });
|
||||||
expect(alarmsStub.create).toHaveBeenCalledTimes(2);
|
expect(alarmsStub.create).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a missing alarm is re-created on the next start", async () => {
|
test("a missing alarm is re-created on the next start", async () => {
|
||||||
@@ -137,7 +155,7 @@ describe("alarms module", () => {
|
|||||||
await alarmsStub.clear(alarmsMod.BALANCE_REFRESH_ALARM);
|
await alarmsStub.clear(alarmsMod.BALANCE_REFRESH_ALARM);
|
||||||
|
|
||||||
const again = await alarmsMod.ensureRecurringAlarms();
|
const again = await alarmsMod.ensureRecurringAlarms();
|
||||||
expect(again).toEqual({ balance: true, phishing: false });
|
expect(again).toEqual({ balance: true, cleared: [] });
|
||||||
expect(
|
expect(
|
||||||
alarmsStub.alarms.get(alarmsMod.BALANCE_REFRESH_ALARM),
|
alarmsStub.alarms.get(alarmsMod.BALANCE_REFRESH_ALARM),
|
||||||
).toBeDefined();
|
).toBeDefined();
|
||||||
@@ -147,17 +165,17 @@ describe("alarms module", () => {
|
|||||||
// An install carries its alarms across an extension update, so a
|
// An install carries its alarms across an extension update, so a
|
||||||
// period changed in a new release only ever reaches users if the
|
// period changed in a new release only ever reaches users if the
|
||||||
// stale one is reconciled.
|
// stale one is reconciled.
|
||||||
alarmsStub.create(alarmsMod.PHISHING_REFRESH_ALARM, {
|
alarmsStub.create(alarmsMod.BALANCE_REFRESH_ALARM, {
|
||||||
periodInMinutes: 7 * 24 * 60,
|
periodInMinutes: 7 * 24 * 60,
|
||||||
});
|
});
|
||||||
alarmsStub.create.mockClear();
|
alarmsStub.create.mockClear();
|
||||||
|
|
||||||
const created = await alarmsMod.ensureRecurringAlarms();
|
const created = await alarmsMod.ensureRecurringAlarms();
|
||||||
expect(created.phishing).toBe(true);
|
expect(created.balance).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
alarmsStub.alarms.get(alarmsMod.PHISHING_REFRESH_ALARM)
|
alarmsStub.alarms.get(alarmsMod.BALANCE_REFRESH_ALARM)
|
||||||
.periodInMinutes,
|
.periodInMinutes,
|
||||||
).toBe(alarmsMod.PHISHING_REFRESH_PERIOD_MINUTES);
|
).toBe(alarmsMod.BALANCE_REFRESH_PERIOD_MINUTES);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("reconciling a period settles instead of re-creating forever", async () => {
|
test("reconciling a period settles instead of re-creating forever", async () => {
|
||||||
@@ -168,31 +186,31 @@ describe("alarms module", () => {
|
|||||||
alarmsStub.create.mockClear();
|
alarmsStub.create.mockClear();
|
||||||
|
|
||||||
const again = await alarmsMod.ensureRecurringAlarms();
|
const again = await alarmsMod.ensureRecurringAlarms();
|
||||||
expect(again).toEqual({ balance: false, phishing: false });
|
expect(again).toEqual({ balance: false, cleared: [] });
|
||||||
expect(alarmsStub.create).not.toHaveBeenCalled();
|
expect(alarmsStub.create).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("handlers are dispatched by alarm name from one listener", () => {
|
test("handlers are dispatched by alarm name from one listener", () => {
|
||||||
const balance = jest.fn();
|
const balance = jest.fn();
|
||||||
const phishing = jest.fn();
|
const other = jest.fn();
|
||||||
expect(
|
expect(
|
||||||
alarmsMod.registerAlarmHandlers({
|
alarmsMod.registerAlarmHandlers({
|
||||||
[alarmsMod.BALANCE_REFRESH_ALARM]: balance,
|
[alarmsMod.BALANCE_REFRESH_ALARM]: balance,
|
||||||
[alarmsMod.PHISHING_REFRESH_ALARM]: phishing,
|
"autistmask-some-other-job": other,
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(alarmsStub.listenerCount()).toBe(1);
|
expect(alarmsStub.listenerCount()).toBe(1);
|
||||||
|
|
||||||
alarmsStub.fire(alarmsMod.BALANCE_REFRESH_ALARM);
|
alarmsStub.fire(alarmsMod.BALANCE_REFRESH_ALARM);
|
||||||
expect(balance).toHaveBeenCalledTimes(1);
|
expect(balance).toHaveBeenCalledTimes(1);
|
||||||
expect(phishing).not.toHaveBeenCalled();
|
expect(other).not.toHaveBeenCalled();
|
||||||
|
|
||||||
alarmsStub.fire(alarmsMod.PHISHING_REFRESH_ALARM);
|
alarmsStub.fire("autistmask-some-other-job");
|
||||||
expect(phishing).toHaveBeenCalledTimes(1);
|
expect(other).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
alarmsStub.fire("some-other-extension-alarm");
|
alarmsStub.fire("an-alarm-with-no-handler");
|
||||||
expect(balance).toHaveBeenCalledTimes(1);
|
expect(balance).toHaveBeenCalledTimes(1);
|
||||||
expect(phishing).toHaveBeenCalledTimes(1);
|
expect(other).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Firefox MV2 gets the same treatment via browser.alarms", async () => {
|
test("Firefox MV2 gets the same treatment via browser.alarms", async () => {
|
||||||
@@ -205,8 +223,8 @@ describe("alarms module", () => {
|
|||||||
try {
|
try {
|
||||||
const mod = require("../src/shared/alarms");
|
const mod = require("../src/shared/alarms");
|
||||||
const created = await mod.ensureRecurringAlarms();
|
const created = await mod.ensureRecurringAlarms();
|
||||||
expect(created).toEqual({ balance: true, phishing: true });
|
expect(created).toEqual({ balance: true, cleared: [] });
|
||||||
expect(firefoxAlarms.created).toHaveLength(2);
|
expect(firefoxAlarms.created).toHaveLength(1);
|
||||||
// The Chrome stub must not have been touched.
|
// The Chrome stub must not have been touched.
|
||||||
expect(alarmsStub.create).not.toHaveBeenCalled();
|
expect(alarmsStub.create).not.toHaveBeenCalled();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -220,7 +238,7 @@ describe("alarms module", () => {
|
|||||||
const mod = require("../src/shared/alarms");
|
const mod = require("../src/shared/alarms");
|
||||||
await expect(mod.ensureRecurringAlarms()).resolves.toEqual({
|
await expect(mod.ensureRecurringAlarms()).resolves.toEqual({
|
||||||
balance: false,
|
balance: false,
|
||||||
phishing: false,
|
cleared: [],
|
||||||
});
|
});
|
||||||
expect(mod.registerAlarmHandlers({})).toBe(false);
|
expect(mod.registerAlarmHandlers({})).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -274,9 +292,12 @@ function loadBackground(initialStore = {}) {
|
|||||||
tabs: { query: jest.fn(), sendMessage: jest.fn() },
|
tabs: { query: jest.fn(), sendMessage: jest.fn() },
|
||||||
action: { setPopup: jest.fn() },
|
action: { setPopup: jest.fn() },
|
||||||
};
|
};
|
||||||
|
// Present so that a startup path which went to the network would be
|
||||||
|
// recorded rather than throwing, which is what makes "no request was made"
|
||||||
|
// an observation instead of an assumption.
|
||||||
global.fetch = jest.fn(async () => ({
|
global.fetch = jest.fn(async () => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({ blacklist: [] }),
|
json: async () => ({}),
|
||||||
}));
|
}));
|
||||||
jest.resetModules();
|
jest.resetModules();
|
||||||
require("../src/background/index");
|
require("../src/background/index");
|
||||||
@@ -318,17 +339,21 @@ describe("background worker scheduling", () => {
|
|||||||
// Let the startup path's promises settle.
|
// Let the startup path's promises settle.
|
||||||
await settle();
|
await settle();
|
||||||
|
|
||||||
const names = alarmsStub.created.map((c) => c.name).sort();
|
const names = alarmsStub.created.map((c) => c.name);
|
||||||
const {
|
const { BALANCE_REFRESH_ALARM } = require("../src/shared/alarms");
|
||||||
BALANCE_REFRESH_ALARM,
|
expect(names).toEqual([BALANCE_REFRESH_ALARM]);
|
||||||
PHISHING_REFRESH_ALARM,
|
|
||||||
} = require("../src/shared/alarms");
|
|
||||||
expect(names).toEqual(
|
|
||||||
[BALANCE_REFRESH_ALARM, PHISHING_REFRESH_ALARM].sort(),
|
|
||||||
);
|
|
||||||
expect(mockSetIntervalCalls).toBe(0);
|
expect(mockSetIntervalCalls).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("startup contacts nothing", async () => {
|
||||||
|
// The phishing blocklist is vendored at build time and there is no
|
||||||
|
// other startup fetch, so a worker coming up asks nobody anything.
|
||||||
|
// Every wake used to be a candidate for a blocklist download.
|
||||||
|
loadBackground();
|
||||||
|
await settle();
|
||||||
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
test("an onAlarm listener is installed on startup", async () => {
|
test("an onAlarm listener is installed on startup", async () => {
|
||||||
alarmsStub = loadBackground().alarmsStub;
|
alarmsStub = loadBackground().alarmsStub;
|
||||||
await settle();
|
await settle();
|
||||||
@@ -348,7 +373,7 @@ describe("background worker scheduling", () => {
|
|||||||
alarmsStub.created.length = 0;
|
alarmsStub.created.length = 0;
|
||||||
loaded.listeners.onStartup[0]();
|
loaded.listeners.onStartup[0]();
|
||||||
await settle();
|
await settle();
|
||||||
expect(alarmsStub.created).toHaveLength(2);
|
expect(alarmsStub.created).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the install-time listener and the top-level call share one run", async () => {
|
test("the install-time listener and the top-level call share one run", async () => {
|
||||||
@@ -360,13 +385,10 @@ describe("background worker scheduling", () => {
|
|||||||
loaded.listeners.onInstalled[0]();
|
loaded.listeners.onInstalled[0]();
|
||||||
await settle();
|
await settle();
|
||||||
|
|
||||||
expect(alarmsStub.created).toHaveLength(2);
|
expect(alarmsStub.created).toHaveLength(1);
|
||||||
expect(alarmsStub.created.map((c) => c.name).sort()).toEqual(
|
expect(alarmsStub.created.map((c) => c.name)).toEqual([
|
||||||
[
|
|
||||||
"autistmask-balance-refresh",
|
"autistmask-balance-refresh",
|
||||||
"autistmask-phishing-refresh",
|
]);
|
||||||
].sort(),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,11 @@
|
|||||||
|
|
||||||
const { Network, Wallet } = require("ethers");
|
const { Network, Wallet } = require("ethers");
|
||||||
|
|
||||||
|
// The real formatter the approval screen renders failures through. Bound here,
|
||||||
|
// before any jest.doMock() of the module, so the copy assertions below check
|
||||||
|
// what the user is actually shown.
|
||||||
|
const { describeSigningFailure } = require("../src/shared/approvalVerify");
|
||||||
|
|
||||||
const SIGNER_KEY =
|
const SIGNER_KEY =
|
||||||
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
|
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
|
||||||
const OTHER_KEY =
|
const OTHER_KEY =
|
||||||
@@ -34,6 +39,21 @@ const HOSTNAME = "dapp.example";
|
|||||||
const UNCONNECTED_ORIGIN = "https://stranger.example";
|
const UNCONNECTED_ORIGIN = "https://stranger.example";
|
||||||
const EXT_URL = "chrome-extension://autistmask/";
|
const EXT_URL = "chrome-extension://autistmask/";
|
||||||
|
|
||||||
|
// An origin the persisted state has never allowed, so asking to connect from
|
||||||
|
// it raises a prompt rather than being answered from allowedSites.
|
||||||
|
const FRESH_ORIGIN = "https://fresh.example";
|
||||||
|
|
||||||
|
// The approval id in the most recent popup URL of a list, or null when none
|
||||||
|
// of them carries one. Takes both shapes: the absolute URL windows.create()
|
||||||
|
// is given and the extension-relative one action.setPopup() is given.
|
||||||
|
function approvalIdIn(urls) {
|
||||||
|
for (let i = urls.length - 1; i >= 0; i--) {
|
||||||
|
if (!urls[i] || !urls[i].includes("?approval=")) continue;
|
||||||
|
return new URL(urls[i], EXT_URL).searchParams.get("approval");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// What the dApp asks for: no nonce, no gas, no fees. This is the shape that
|
// What the dApp asks for: no nonce, no gas, no fees. This is the shape that
|
||||||
// makes a duplicate broadcast possible at all.
|
// makes a duplicate broadcast possible at all.
|
||||||
const TX_PARAMS = {
|
const TX_PARAMS = {
|
||||||
@@ -137,16 +157,21 @@ function loadBackground(options) {
|
|||||||
}));
|
}));
|
||||||
jest.doMock("../src/shared/phishingDomains", () => ({
|
jest.doMock("../src/shared/phishingDomains", () => ({
|
||||||
isPhishingDomain: () => false,
|
isPhishingDomain: () => false,
|
||||||
refreshPhishingListOnSchedule: jest.fn(async () => {}),
|
|
||||||
initPhishingList: jest.fn(async () => {}),
|
|
||||||
}));
|
}));
|
||||||
jest.doMock("../src/shared/alarms", () => ({
|
jest.doMock("../src/shared/alarms", () => ({
|
||||||
BALANCE_REFRESH_ALARM: "balance",
|
BALANCE_REFRESH_ALARM: "balance",
|
||||||
PHISHING_REFRESH_ALARM: "phishing",
|
|
||||||
BALANCE_REFRESH_PERIOD_MINUTES: 1,
|
BALANCE_REFRESH_PERIOD_MINUTES: 1,
|
||||||
ensureRecurringAlarms: jest.fn(async () => {}),
|
ensureRecurringAlarms: jest.fn(async () => {}),
|
||||||
registerAlarmHandlers: jest.fn(),
|
registerAlarmHandlers: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
// The real verification module, except where a test replaces one export
|
||||||
|
// with a throw to drive the handler's own error handling into failing.
|
||||||
|
if (opts.approvalVerify) {
|
||||||
|
jest.doMock("../src/shared/approvalVerify", () => ({
|
||||||
|
...jest.requireActual("../src/shared/approvalVerify"),
|
||||||
|
...opts.approvalVerify,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
const persisted = {
|
const persisted = {
|
||||||
wallets: [
|
wallets: [
|
||||||
@@ -160,13 +185,21 @@ function loadBackground(options) {
|
|||||||
|
|
||||||
let messageListener = null;
|
let messageListener = null;
|
||||||
let windowRemovedListener = null;
|
let windowRemovedListener = null;
|
||||||
|
let connectListener = null;
|
||||||
const created = [];
|
const created = [];
|
||||||
const removed = [];
|
const removed = [];
|
||||||
|
// Every URL the background put on the browser action. A site approval
|
||||||
|
// raised through action.openPopup() opens no window at all, so this is
|
||||||
|
// the only place its id appears.
|
||||||
|
const actionPopups = [];
|
||||||
|
|
||||||
global.chrome = {
|
global.chrome = {
|
||||||
storage: {
|
storage: {
|
||||||
local: {
|
local: {
|
||||||
get: jest.fn(async () => ({ autistmask: persisted })),
|
get: jest.fn(
|
||||||
|
opts.storageGet ||
|
||||||
|
(async () => ({ autistmask: persisted })),
|
||||||
|
),
|
||||||
set: jest.fn(async () => {}),
|
set: jest.fn(async () => {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -177,7 +210,14 @@ function loadBackground(options) {
|
|||||||
messageListener = fn;
|
messageListener = fn;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
onConnect: { addListener: () => {} },
|
// Captured, not swallowed: the approval port is what carries a
|
||||||
|
// site connection's decision and the popup teardown that races
|
||||||
|
// it, so a no-op stub here hides the whole subject of #275.
|
||||||
|
onConnect: {
|
||||||
|
addListener: (fn) => {
|
||||||
|
connectListener = fn;
|
||||||
|
},
|
||||||
|
},
|
||||||
lastError: null,
|
lastError: null,
|
||||||
},
|
},
|
||||||
windows: {
|
windows: {
|
||||||
@@ -205,7 +245,17 @@ function loadBackground(options) {
|
|||||||
query: (q, cb) => cb([]),
|
query: (q, cb) => cb([]),
|
||||||
sendMessage: () => {},
|
sendMessage: () => {},
|
||||||
},
|
},
|
||||||
action: { setPopup: () => {} },
|
action: {
|
||||||
|
setPopup: (o) => {
|
||||||
|
actionPopups.push(o.popup);
|
||||||
|
},
|
||||||
|
// The production route for a site connection. Present only when
|
||||||
|
// a test asks for it, because with it the prompt is the toolbar
|
||||||
|
// popup: no window is created, so windows.onRemoved can never
|
||||||
|
// fire for it and the port disconnect is the only close signal
|
||||||
|
// that exists.
|
||||||
|
...(opts.actionPopup ? { openPopup: () => Promise.resolve() } : {}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
require("../src/background/index");
|
require("../src/background/index");
|
||||||
@@ -273,6 +323,70 @@ function loadBackground(options) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A dApp asking to connect. The origin defaults to one the persisted
|
||||||
|
// state has never allowed, so the request really does raise a prompt
|
||||||
|
// instead of being answered from allowedSites.
|
||||||
|
function requestSite(origin) {
|
||||||
|
let rpcResult = null;
|
||||||
|
messageListener(
|
||||||
|
{
|
||||||
|
type: "AUTISTMASK_RPC",
|
||||||
|
method: "eth_requestAccounts",
|
||||||
|
params: [],
|
||||||
|
},
|
||||||
|
{ origin: origin || FRESH_ORIGIN },
|
||||||
|
(r) => {
|
||||||
|
rpcResult = r;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
// Wherever the prompt went: the toolbar popup URL when
|
||||||
|
// action.openPopup() carried it, the created window otherwise.
|
||||||
|
id: () =>
|
||||||
|
approvalIdIn(actionPopups) ||
|
||||||
|
approvalIdIn(created.map((c) => c.url)),
|
||||||
|
result: () => rpcResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The popup's approval port, as the browser delivers it. Messages posted
|
||||||
|
// on a port and that port's disconnect travel one channel in FIFO order,
|
||||||
|
// which is exactly the property the fix rests on, so this stub delivers
|
||||||
|
// them in the order the caller emits them and never reorders them.
|
||||||
|
function connectApproval(id, senderUrl) {
|
||||||
|
const onMessage = [];
|
||||||
|
const onDisconnect = [];
|
||||||
|
const port = {
|
||||||
|
name: "approval:" + id,
|
||||||
|
sender: {
|
||||||
|
url:
|
||||||
|
senderUrl === undefined
|
||||||
|
? EXT_URL + "src/popup/index.html?approval=" + id
|
||||||
|
: senderUrl,
|
||||||
|
},
|
||||||
|
onMessage: { addListener: (fn) => onMessage.push(fn) },
|
||||||
|
onDisconnect: { addListener: (fn) => onDisconnect.push(fn) },
|
||||||
|
};
|
||||||
|
connectListener(port);
|
||||||
|
return {
|
||||||
|
decide: (approved, remember) => {
|
||||||
|
for (const fn of onMessage) {
|
||||||
|
fn(
|
||||||
|
{
|
||||||
|
type: "AUTISTMASK_APPROVAL_DECISION",
|
||||||
|
approved,
|
||||||
|
remember: !!remember,
|
||||||
|
},
|
||||||
|
port,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
disconnect: () => {
|
||||||
|
for (const fn of onDisconnect) fn(port);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// The user closes the approval popup. `created` is index-aligned with the
|
// The user closes the approval popup. `created` is index-aligned with the
|
||||||
// ids the window stub hands back, so window 1 is the first popup opened.
|
// ids the window stub hands back, so window 1 is the first popup opened.
|
||||||
function closeWindow(windowId) {
|
function closeWindow(windowId) {
|
||||||
@@ -283,6 +397,8 @@ function loadBackground(options) {
|
|||||||
send,
|
send,
|
||||||
requestTx,
|
requestTx,
|
||||||
requestSign,
|
requestSign,
|
||||||
|
requestSite,
|
||||||
|
connectApproval,
|
||||||
closeWindow,
|
closeWindow,
|
||||||
broadcastTransaction,
|
broadcastTransaction,
|
||||||
loadState,
|
loadState,
|
||||||
@@ -309,6 +425,15 @@ async function settle() {
|
|||||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// settle() only drains microtasks. A handler whose last-resort .catch() has to
|
||||||
|
// run after a macrotask boundary needs those turns too, so the assertion that
|
||||||
|
// the page WAS answered is what reports a regression rather than a timeout.
|
||||||
|
async function settleIncludingRejections() {
|
||||||
|
await settle();
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
delete global.chrome;
|
delete global.chrome;
|
||||||
jest.resetModules();
|
jest.resetModules();
|
||||||
@@ -1375,6 +1500,200 @@ describe("a claimed approval outlives every other retirement path", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A handler that throws must still answer. `sendResponse` is the only thing
|
||||||
|
// that settles the page's window.ethereum.request() promise, so a throw that
|
||||||
|
// escapes a handler leaves that promise pending forever — no error, no
|
||||||
|
// timeout, indistinguishable from a slow wallet. Each case below drives a real
|
||||||
|
// throw out of a handler rather than asserting the catch block exists.
|
||||||
|
describe("a handler that throws still settles the page", () => {
|
||||||
|
const INTERNAL_ERROR = {
|
||||||
|
code: -32603,
|
||||||
|
message:
|
||||||
|
"AutistMask could not complete this request because of an internal error.",
|
||||||
|
};
|
||||||
|
|
||||||
|
let errorLog;
|
||||||
|
beforeEach(() => {
|
||||||
|
errorLog = jest.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
errorLog.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
// getState() awaits extension storage unguarded, and every read path in
|
||||||
|
// handleRpc goes through it. A storage read that rejects is the whole
|
||||||
|
// failure — no hook in the handler itself.
|
||||||
|
test("a rejected handleRpc rejects the page instead of hanging it", async () => {
|
||||||
|
const bg = loadBackground({
|
||||||
|
storageGet: async () => {
|
||||||
|
throw new Error("storage unavailable");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const answer = bg.send(
|
||||||
|
{ type: "AUTISTMASK_RPC", method: "eth_accounts", params: [] },
|
||||||
|
{ origin: ORIGIN },
|
||||||
|
);
|
||||||
|
await settleIncludingRejections();
|
||||||
|
|
||||||
|
// The channel is held open for the async answer, and the answer
|
||||||
|
// arrives.
|
||||||
|
expect(answer.kept).toBe(true);
|
||||||
|
expect(answer.sendResponse).toHaveBeenCalledWith({
|
||||||
|
error: INTERNAL_ERROR,
|
||||||
|
});
|
||||||
|
// Not swallowed: the throw is on the background console, which is how
|
||||||
|
// this class gets caught in future.
|
||||||
|
expect(errorLog).toHaveBeenCalledWith(
|
||||||
|
"[AutistMask]",
|
||||||
|
"RPC request failed:",
|
||||||
|
"eth_accounts",
|
||||||
|
expect.objectContaining({ message: "storage unavailable" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The transaction response handler wraps every statement in a try, so what
|
||||||
|
// escapes it is a throw from inside one of its catch blocks. Here the
|
||||||
|
// failure classifier itself throws while classifying a real verification
|
||||||
|
// failure — the approval is left claimed, so nothing else can settle it.
|
||||||
|
// The escape happens before broadcastTransaction() is reached, so the
|
||||||
|
// reported stage must be the one that says the transaction is gone.
|
||||||
|
test("a throw while verifying a transaction settles both the page and the popup", async () => {
|
||||||
|
const bg = loadBackground({
|
||||||
|
approvalVerify: {
|
||||||
|
describeTxFailure: () => {
|
||||||
|
throw new Error("classifier broke");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const pending = bg.requestTx();
|
||||||
|
await settle();
|
||||||
|
const id = pending.id();
|
||||||
|
|
||||||
|
// A real verification failure: the artifact is signed at a nonce the
|
||||||
|
// approval never displayed.
|
||||||
|
const answer = bg.send(
|
||||||
|
{
|
||||||
|
type: "AUTISTMASK_TX_RESPONSE",
|
||||||
|
id,
|
||||||
|
approved: true,
|
||||||
|
rawSignedTx: await signedAtNonce(NONCE + 1),
|
||||||
|
},
|
||||||
|
{ url: bg.fromPopup.url },
|
||||||
|
);
|
||||||
|
await settleIncludingRejections();
|
||||||
|
|
||||||
|
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
|
||||||
|
expect(pending.result()).toEqual({ error: INTERNAL_ERROR });
|
||||||
|
expect(answer.sendResponse).toHaveBeenCalledWith({
|
||||||
|
error: INTERNAL_ERROR.message,
|
||||||
|
retryable: false,
|
||||||
|
// Nothing was broadcast, so the popup must say the request is gone
|
||||||
|
// rather than that it may still have reached the network.
|
||||||
|
stage: "verify",
|
||||||
|
});
|
||||||
|
expect(errorLog).toHaveBeenCalledWith(
|
||||||
|
"[AutistMask]",
|
||||||
|
"transaction approval response failed:",
|
||||||
|
expect.objectContaining({ message: "classifier broke" }),
|
||||||
|
);
|
||||||
|
// The copy the user actually reads, from the popup's own formatter.
|
||||||
|
expect(
|
||||||
|
describeSigningFailure(answer.sendResponse.mock.calls[0][0], "")
|
||||||
|
.message,
|
||||||
|
).toBe(
|
||||||
|
INTERNAL_ERROR.message +
|
||||||
|
" This request can no longer be signed." +
|
||||||
|
" Please start it again from the site.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The other side of the same local: once broadcastTransaction() has been
|
||||||
|
// entered the wallet genuinely cannot tell whether the node took the
|
||||||
|
// transaction, and the copy that warns about a second send is correct.
|
||||||
|
test("a throw while handling a failed broadcast reports the broadcast stage", async () => {
|
||||||
|
const bg = loadBackground({
|
||||||
|
approvalVerify: {
|
||||||
|
describeTxFailure: () => {
|
||||||
|
throw new Error("classifier broke");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
bg.broadcastTransaction.mockRejectedValue(new Error("node refused"));
|
||||||
|
const pending = bg.requestTx();
|
||||||
|
await settle();
|
||||||
|
const id = pending.id();
|
||||||
|
|
||||||
|
// The approved artifact, so verification passes and the failure
|
||||||
|
// happens at the broadcast.
|
||||||
|
const answer = bg.send(
|
||||||
|
{
|
||||||
|
type: "AUTISTMASK_TX_RESPONSE",
|
||||||
|
id,
|
||||||
|
approved: true,
|
||||||
|
rawSignedTx: await signedAtNonce(NONCE),
|
||||||
|
},
|
||||||
|
{ url: bg.fromPopup.url },
|
||||||
|
);
|
||||||
|
await settleIncludingRejections();
|
||||||
|
|
||||||
|
expect(bg.broadcastTransaction).toHaveBeenCalled();
|
||||||
|
expect(pending.result()).toEqual({ error: INTERNAL_ERROR });
|
||||||
|
expect(answer.sendResponse).toHaveBeenCalledWith({
|
||||||
|
error: INTERNAL_ERROR.message,
|
||||||
|
retryable: false,
|
||||||
|
stage: "broadcast",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
describeSigningFailure(answer.sendResponse.mock.calls[0][0], "")
|
||||||
|
.message,
|
||||||
|
).toBe(
|
||||||
|
INTERNAL_ERROR.message +
|
||||||
|
" The transaction may still have reached the network." +
|
||||||
|
" Check the account before sending it again.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a throw while handling a failed signature settles both the page and the popup", async () => {
|
||||||
|
const bg = loadBackground({
|
||||||
|
approvalVerify: {
|
||||||
|
failureIsRetryable: () => {
|
||||||
|
throw new Error("classifier broke");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const pending = bg.requestSign();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
// A real verification failure: the active address moved after the
|
||||||
|
// approval was raised.
|
||||||
|
bg.setActiveAddress(other.address);
|
||||||
|
const answer = bg.send(
|
||||||
|
{
|
||||||
|
type: "AUTISTMASK_SIGN_RESPONSE",
|
||||||
|
id: pending.id(),
|
||||||
|
approved: true,
|
||||||
|
signature: await signer.signMessage(
|
||||||
|
Buffer.from(MESSAGE.slice(2), "hex"),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ url: bg.fromPopup.url },
|
||||||
|
);
|
||||||
|
await settleIncludingRejections();
|
||||||
|
|
||||||
|
expect(pending.result()).toEqual({ error: INTERNAL_ERROR });
|
||||||
|
expect(answer.sendResponse).toHaveBeenCalledWith({
|
||||||
|
error: INTERNAL_ERROR.message,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
expect(errorLog).toHaveBeenCalledWith(
|
||||||
|
"[AutistMask]",
|
||||||
|
"sign approval response failed:",
|
||||||
|
expect.objectContaining({ message: "classifier broke" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("popup-only messages", () => {
|
describe("popup-only messages", () => {
|
||||||
test("a page sender cannot answer an approval", async () => {
|
test("a page sender cannot answer an approval", async () => {
|
||||||
const bg = loadBackground();
|
const bg = loadBackground();
|
||||||
@@ -1399,3 +1718,197 @@ describe("popup-only messages", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A site connection decided in a popup that closes on the next line.
|
||||||
|
//
|
||||||
|
// The decision and the teardown are two events the popup emits back to back,
|
||||||
|
// and the background must not be able to reach different outcomes depending on
|
||||||
|
// which of them it processes first. It cannot, because they are now one
|
||||||
|
// channel: the decision is posted on the approval port that the close then
|
||||||
|
// disconnects, so it is delivered first. Every test here therefore emits the
|
||||||
|
// close IMMEDIATELY after the decision, with nothing awaited in between —
|
||||||
|
// which is what the popup does, and what used to report a user who approved as
|
||||||
|
// having refused (#275).
|
||||||
|
describe("a site connection decided as the popup closes", () => {
|
||||||
|
// The production route: chrome.action.openPopup() put the prompt in the
|
||||||
|
// toolbar popup, which is not a window, so nothing but the port
|
||||||
|
// disconnect can tell the background this prompt is gone.
|
||||||
|
test("approving in the toolbar popup connects the site", async () => {
|
||||||
|
const bg = loadBackground({ actionPopup: true });
|
||||||
|
const pending = bg.requestSite();
|
||||||
|
await settle();
|
||||||
|
const id = pending.id();
|
||||||
|
expect(id).toBeTruthy();
|
||||||
|
expect(bg.created).toHaveLength(0);
|
||||||
|
|
||||||
|
const port = bg.connectApproval(id);
|
||||||
|
port.decide(true, false);
|
||||||
|
port.disconnect();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(pending.result()).toEqual({ result: [signer.address] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closing the toolbar popup without deciding is a rejection", async () => {
|
||||||
|
const bg = loadBackground({ actionPopup: true });
|
||||||
|
const pending = bg.requestSite();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
const port = bg.connectApproval(pending.id());
|
||||||
|
port.disconnect();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(pending.result()).toEqual({
|
||||||
|
error: { code: 4001, message: "User rejected the request." },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejecting is a rejection, and the close that follows adds nothing", async () => {
|
||||||
|
const bg = loadBackground({ actionPopup: true });
|
||||||
|
const pending = bg.requestSite();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
const port = bg.connectApproval(pending.id());
|
||||||
|
port.decide(false, false);
|
||||||
|
port.disconnect();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(pending.result()).toEqual({
|
||||||
|
error: { code: 4001, message: "User rejected the request." },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The port carries a decision now, so it carries the sender check the
|
||||||
|
// one-off message used to carry. A content script that guessed an
|
||||||
|
// approval id must not be able to connect the site it is running on.
|
||||||
|
test("a decision from a page sender is ignored, and the close rejects", async () => {
|
||||||
|
const bg = loadBackground({ actionPopup: true });
|
||||||
|
const pending = bg.requestSite();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
const port = bg.connectApproval(pending.id(), FRESH_ORIGIN + "/x.html");
|
||||||
|
port.decide(true, true);
|
||||||
|
await settle();
|
||||||
|
expect(pending.result()).toBeNull();
|
||||||
|
|
||||||
|
port.disconnect();
|
||||||
|
await settle();
|
||||||
|
expect(pending.result()).toEqual({
|
||||||
|
error: { code: 4001, message: "User rejected the request." },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The fallback shape, where openPopup() is unavailable and the prompt is
|
||||||
|
// a window the extension opened. Closing it fires windows.onRemoved as
|
||||||
|
// well, on a channel of its own that is ordered against nothing — so the
|
||||||
|
// window event must not be allowed to decide a site approval either.
|
||||||
|
//
|
||||||
|
// The event goes FIRST here, which is the interleaving the guard in the
|
||||||
|
// onRemoved listener exists for: the approval is still pending when the
|
||||||
|
// event arrives, so the listener really reaches it and really has to
|
||||||
|
// decline it. With the decision first there is nothing left in
|
||||||
|
// pendingApprovals and the listener finds no approval to spare.
|
||||||
|
test("approving in the fallback window survives a window event that lands first", async () => {
|
||||||
|
const bg = loadBackground();
|
||||||
|
const pending = bg.requestSite();
|
||||||
|
await settle();
|
||||||
|
expect(bg.created).toHaveLength(1);
|
||||||
|
|
||||||
|
const port = bg.connectApproval(pending.id());
|
||||||
|
bg.closeWindow(1);
|
||||||
|
port.decide(true, false);
|
||||||
|
port.disconnect();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(pending.result()).toEqual({ result: [signer.address] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("approving in the fallback window survives a window event that follows", async () => {
|
||||||
|
const bg = loadBackground();
|
||||||
|
const pending = bg.requestSite();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
const port = bg.connectApproval(pending.id());
|
||||||
|
port.decide(true, false);
|
||||||
|
bg.closeWindow(1);
|
||||||
|
port.disconnect();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(pending.result()).toEqual({ result: [signer.address] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// The connected port is what silences the window event, so connecting one
|
||||||
|
// must take the same sender check the decision takes. Otherwise a content
|
||||||
|
// script that guessed the id switches off the only settlement path a
|
||||||
|
// prompt whose real popup never connected has, and the dApp hangs.
|
||||||
|
test("a port from a page sender does not silence the window event", async () => {
|
||||||
|
const bg = loadBackground();
|
||||||
|
const pending = bg.requestSite();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
// Connected and held open — no disconnect, so nothing but the window
|
||||||
|
// event can settle this approval.
|
||||||
|
bg.connectApproval(pending.id(), FRESH_ORIGIN + "/x.html");
|
||||||
|
bg.closeWindow(1);
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(pending.result()).toEqual({
|
||||||
|
error: { code: 4001, message: "User rejected the request." },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Same shape, and the same window event arriving before the popup has
|
||||||
|
// said anything at all — which is a user closing the window rather than
|
||||||
|
// deciding, and still has to reach the dApp as a rejection.
|
||||||
|
test("closing the fallback window without deciding is a rejection", async () => {
|
||||||
|
const bg = loadBackground();
|
||||||
|
const pending = bg.requestSite();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
const port = bg.connectApproval(pending.id());
|
||||||
|
bg.closeWindow(1);
|
||||||
|
port.disconnect();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(pending.result()).toEqual({
|
||||||
|
error: { code: 4001, message: "User rejected the request." },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The net under the paragraph above: a prompt whose page never got as far
|
||||||
|
// as connecting the port has no disconnect to reject it, so the window
|
||||||
|
// event has to. Otherwise the dApp waits forever on a window that is gone.
|
||||||
|
test("a window that closes before its popup ever connected still rejects", async () => {
|
||||||
|
const bg = loadBackground();
|
||||||
|
const pending = bg.requestSite();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
bg.closeWindow(1);
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(pending.result()).toEqual({
|
||||||
|
error: { code: 4001, message: "User rejected the request." },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The `isSite &&` half of that skip, which is what keeps it from reaching
|
||||||
|
// a tx or sign approval. The popup connects its port in show() before it
|
||||||
|
// knows the approval's type, and the background sets portConnected without
|
||||||
|
// looking at the type either, so a tx approval in the fallback window
|
||||||
|
// carries the flag too. Without the conjunct the window event would skip
|
||||||
|
// it, windowClosed would never be set, releaseApproval() would never settle
|
||||||
|
// it, and the page would hang — the #271 regression this guard is written
|
||||||
|
// around.
|
||||||
|
test("a tx window closed with the port connected still rejects", async () => {
|
||||||
|
const bg = loadBackground();
|
||||||
|
const pending = bg.requestTx();
|
||||||
|
await settle();
|
||||||
|
bg.connectApproval(pending.id());
|
||||||
|
await settle();
|
||||||
|
bg.closeWindow(1);
|
||||||
|
await settle();
|
||||||
|
expect(pending.result()).toEqual({
|
||||||
|
error: { code: 4001, message: "User rejected the request." },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
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;
|
let parsed;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(text);
|
parsed = JSON.parse(text);
|
||||||
} catch (_) {
|
} catch {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
method + " " + path + ": non-JSON response: " + text,
|
method + " " + path + ": non-JSON response: " + text,
|
||||||
);
|
);
|
||||||
@@ -110,6 +110,26 @@ class Driver {
|
|||||||
"extensions.webextensions.uuids": JSON.stringify({
|
"extensions.webextensions.uuids": JSON.stringify({
|
||||||
[EXTENSION_ID]: EXTENSION_UUID,
|
[EXTENSION_ID]: EXTENSION_UUID,
|
||||||
}),
|
}),
|
||||||
|
// The container has loopback and nothing else. Firefox's own
|
||||||
|
// link-status detection can read that as "offline" and then
|
||||||
|
// refuse every request, including the ones to the loopback dApp
|
||||||
|
// origin the suite serves; this takes the decision away from it.
|
||||||
|
"network.manage-offline-status": false,
|
||||||
|
// Force the site-connection prompt down its windows.create()
|
||||||
|
// fallback.
|
||||||
|
//
|
||||||
|
// src/background/index.js prefers the toolbar-anchored popup for
|
||||||
|
// that one approval and opens a real window only when
|
||||||
|
// openPopup() refuses. A panel is not a top-level browsing
|
||||||
|
// context, so WebDriver cannot see it, list it or click in it —
|
||||||
|
// the same blind spot the Chrome harness documents. Leaving this
|
||||||
|
// at its default would make which path runs depend on whether a
|
||||||
|
// headless Firefox counts as having had a user gesture, which is
|
||||||
|
// not a thing to leave to chance in a suite that has to be able
|
||||||
|
// to fail. The window path is shipped code and the same approval
|
||||||
|
// id, so what is driven is real; what is NOT covered either way
|
||||||
|
// is the panel presentation itself.
|
||||||
|
"extensions.openPopupWithoutUserGesture.enabled": false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const value = await this.send("POST", "/session", {
|
const value = await this.send("POST", "/session", {
|
||||||
@@ -179,6 +199,16 @@ class Driver {
|
|||||||
return this.session("POST", "/execute/sync", { script, args });
|
return this.session("POST", "/execute/sync", { script, args });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The asynchronous form: the script is handed a resolve callback as its
|
||||||
|
// last argument and the call settles when that is invoked. Everything
|
||||||
|
// interesting about an extension page is promise-shaped — storage reads,
|
||||||
|
// the provider's own request() — and /execute/sync cannot wait for any
|
||||||
|
// of it.
|
||||||
|
async executeAsync(script, args = []) {
|
||||||
|
await this.setContext("content");
|
||||||
|
return this.session("POST", "/execute/async", { script, args });
|
||||||
|
}
|
||||||
|
|
||||||
// Runs in the privileged chrome scope, where Services and Ci exist.
|
// Runs in the privileged chrome scope, where Services and Ci exist.
|
||||||
async executeChrome(script, args = []) {
|
async executeChrome(script, args = []) {
|
||||||
await this.setContext("chrome");
|
await this.setContext("chrome");
|
||||||
@@ -199,7 +229,9 @@ class Driver {
|
|||||||
// condition it was waiting on rather than "timed out".
|
// condition it was waiting on rather than "timed out".
|
||||||
async waitFor(what, script, args = [], timeout = DEFAULT_WAIT_MS) {
|
async waitFor(what, script, args = [], timeout = DEFAULT_WAIT_MS) {
|
||||||
const deadline = Date.now() + timeout;
|
const deadline = Date.now() + timeout;
|
||||||
let last = null;
|
// Assigned on every path through the loop body before it is read, so
|
||||||
|
// there is no initializer to give it.
|
||||||
|
let last;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
try {
|
try {
|
||||||
const v = await this.execute(script, args);
|
const v = await this.execute(script, args);
|
||||||
@@ -329,6 +361,63 @@ class Driver {
|
|||||||
[selector],
|
[selector],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ windows
|
||||||
|
//
|
||||||
|
// The approval prompts this suite drives are separate top-level windows
|
||||||
|
// the extension opens itself, so every one of them is a window handle
|
||||||
|
// here and the suite has to move between them explicitly.
|
||||||
|
|
||||||
|
async windowHandles() {
|
||||||
|
return this.session("GET", "/window/handles");
|
||||||
|
}
|
||||||
|
|
||||||
|
async currentWindow() {
|
||||||
|
return this.session("GET", "/window");
|
||||||
|
}
|
||||||
|
|
||||||
|
async switchToWindow(handle) {
|
||||||
|
await this.setContext("content");
|
||||||
|
await this.session("POST", "/window", { handle });
|
||||||
|
}
|
||||||
|
|
||||||
|
async newWindow(type = "window") {
|
||||||
|
await this.setContext("content");
|
||||||
|
const value = await this.session("POST", "/window/new", { type });
|
||||||
|
return value.handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closes the current window and leaves the session on `fallback`, because
|
||||||
|
// a session whose current window is gone fails every subsequent command
|
||||||
|
// with "no such window" rather than with anything diagnosable.
|
||||||
|
async closeWindow(fallback) {
|
||||||
|
await this.setContext("content");
|
||||||
|
await this.session("DELETE", "/window");
|
||||||
|
if (fallback) await this.switchToWindow(fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
async url() {
|
||||||
|
return this.session("GET", "/url");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The handle of the first window whose URL matches, or null. Restores the
|
||||||
|
// window that was current before the search either way: a probe that
|
||||||
|
// silently relocates the session is a trap for the step after it.
|
||||||
|
async findWindow(predicate) {
|
||||||
|
const origin = await this.currentWindow();
|
||||||
|
try {
|
||||||
|
for (const handle of await this.windowHandles()) {
|
||||||
|
await this.switchToWindow(handle);
|
||||||
|
if (predicate(await this.url())) return handle;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
// Tolerated: the window the search started from may have been the
|
||||||
|
// one that just closed, and a throw in here would replace the
|
||||||
|
// real result with "no such window".
|
||||||
|
await this.switchToWindow(origin).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------- error capture
|
// ------------------------------------------------------- error capture
|
||||||
@@ -349,10 +438,15 @@ class Driver {
|
|||||||
// background page, which BiDi would not have covered even if it worked.
|
// background page, which BiDi would not have covered even if it worked.
|
||||||
// Background-page capture is verified by probe — a throw at the top of
|
// Background-page capture is verified by probe — a throw at the top of
|
||||||
// src/background/index.js, which kills the background page outright, fails
|
// src/background/index.js, which kills the background page outright, fails
|
||||||
// the run. Content-script errors should arrive by the same route, but that
|
// the run.
|
||||||
// is UNVERIFIED here and must not be claimed: the container runs with
|
//
|
||||||
// --network none, so there is no http:// page for a content script to be
|
// Content scripts ARE now exercised: tests/e2e/firefox/dapp.js serves a page
|
||||||
// injected into and this suite never exercises one.
|
// from loopback, which survives --network none, and the suite drives the
|
||||||
|
// EIP-1193 round trips through the content script injected into it. What is
|
||||||
|
// still unproven is the CAPTURE, not the execution — no probe has forced a
|
||||||
|
// throw from inside a content script and watched it fail the run, so an
|
||||||
|
// uncaught content-script error arriving by this route remains an
|
||||||
|
// expectation rather than a demonstrated fact. Do not claim otherwise.
|
||||||
//
|
//
|
||||||
// Warnings are excluded so the semantics match Playwright's pageerror:
|
// Warnings are excluded so the semantics match Playwright's pageerror:
|
||||||
// uncaught errors only.
|
// uncaught errors only.
|
||||||
@@ -420,7 +514,7 @@ async function waitForDriverReady(base, timeoutMs) {
|
|||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
if (body && body.value && body.value.ready !== false) return;
|
if (body && body.value && body.value.ready !== false) return;
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch {
|
||||||
// not listening yet
|
// not listening yet
|
||||||
}
|
}
|
||||||
if (Date.now() >= deadline) {
|
if (Date.now() >= deadline) {
|
||||||
|
|||||||
@@ -15,8 +15,15 @@
|
|||||||
// UI steps below are written twice on purpose. Chrome runs on Playwright,
|
// UI steps below are written twice on purpose. Chrome runs on Playwright,
|
||||||
// which cannot see extension-page errors in Firefox at all (see the BiDi
|
// which cannot see extension-page errors in Firefox at all (see the BiDi
|
||||||
// note in driver.js), so the two backends have no common substrate to
|
// note in driver.js), so the two backends have no common substrate to
|
||||||
// abstract over. Three duplicated steps do not pay for a shim; revisit if
|
// abstract over. Duplicated steps do not pay for a shim; revisit if this
|
||||||
// this suite grows to where they do.
|
// suite grows to where they do. What IS shared is the dApp page fixture
|
||||||
|
// itself — DAPP_HTML, served here from loopback by dapp.js — so an assertion
|
||||||
|
// about the __dapp API means the same thing on both browsers.
|
||||||
|
//
|
||||||
|
// The dApp steps need an http:// origin, which --network none was thought to
|
||||||
|
// rule out. It does not: loopback survives it, so the page and the stub node
|
||||||
|
// are served from 127.0.0.1 inside the container and the run reaches nothing
|
||||||
|
// but this process. See tests/e2e/firefox/dapp.js.
|
||||||
//
|
//
|
||||||
// LIMITATION, and the difference from the Chrome suite worth knowing: error
|
// LIMITATION, and the difference from the Chrome suite worth knowing: error
|
||||||
// capture here is POLL-BASED, not event-streamed. The console service is
|
// capture here is POLL-BASED, not event-streamed. The console service is
|
||||||
@@ -40,7 +47,21 @@
|
|||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
||||||
|
const {
|
||||||
|
Transaction,
|
||||||
|
formatEther,
|
||||||
|
getAddress,
|
||||||
|
getBytes,
|
||||||
|
hexlify,
|
||||||
|
parseEther,
|
||||||
|
toQuantity,
|
||||||
|
toUtf8Bytes,
|
||||||
|
verifyMessage,
|
||||||
|
} = require("ethers");
|
||||||
|
|
||||||
const { ConsoleErrors, EXTENSION_ORIGIN, start, sleep } = require("./driver");
|
const { ConsoleErrors, EXTENSION_ORIGIN, start, sleep } = require("./driver");
|
||||||
|
const { startDappServer } = require("./dapp");
|
||||||
|
const { STUB_COUNTERPARTY } = require("../network");
|
||||||
|
|
||||||
const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
|
const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
|
||||||
const POPUP_URL = EXTENSION_ORIGIN + "/src/popup/index.html";
|
const POPUP_URL = EXTENSION_ORIGIN + "/src/popup/index.html";
|
||||||
@@ -137,8 +158,598 @@ step("add token screen opens from address detail", async (env) => {
|
|||||||
assert(picks > 0, "no common-token quick-pick buttons rendered");
|
assert(picks > 0, "no common-token quick-pick buttons rendered");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------- the dApp round trips
|
||||||
|
//
|
||||||
|
// Everything above drives the popup on its own. From here the page, the
|
||||||
|
// content script, the inpage provider, the background page and the approval
|
||||||
|
// window all have to work together — the paths
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/153 rewrote, and the ones no
|
||||||
|
// Firefox test reached before. They are asserted here because nothing else
|
||||||
|
// covers them on this browser, not because they were broken: these steps
|
||||||
|
// pass against the pre-refactor callback code too, which is how the issue's
|
||||||
|
// premise was refuted.
|
||||||
|
//
|
||||||
|
// The shape is the Chrome suite's (tests/e2e/run.js, the #183 section) and
|
||||||
|
// the assertions mean the same things:
|
||||||
|
//
|
||||||
|
// - the signature is recovered here, in the runner, from the artifact the
|
||||||
|
// extension produced, and compared against the address read out of
|
||||||
|
// extension storage. The background verifies too; these assertions do not
|
||||||
|
// lean on that, because a test that trusted the wallet's own verdict would
|
||||||
|
// pass against a wallet that verified nothing.
|
||||||
|
// - the transaction is asserted against the raw signed transaction that
|
||||||
|
// reached the stub node, not against anything the extension reported.
|
||||||
|
//
|
||||||
|
// What this does NOT cover: a real dApp with real funds against a real
|
||||||
|
// network. The node is a fixture on loopback.
|
||||||
|
|
||||||
|
const SIGN_TEXT = "AutistMask e2e round trip: personal_sign";
|
||||||
|
const SIGN_HEX = hexlify(toUtf8Bytes(SIGN_TEXT));
|
||||||
|
|
||||||
|
const TX_VALUE_ETH = "0.0123";
|
||||||
|
const TX_VALUE_WEI = parseEther(TX_VALUE_ETH);
|
||||||
|
// Call data that decodes as nothing, so the screen assertion compares the
|
||||||
|
// calldata itself rather than a decoder's summary of it.
|
||||||
|
const TX_DATA = "0xdeadbeef" + "01".repeat(28);
|
||||||
|
|
||||||
|
const USER_REJECTION_MESSAGE = "User rejected the request.";
|
||||||
|
|
||||||
|
// Read the extension's persisted state, point its rpcUrl at the loopback stub
|
||||||
|
// node, and hand back the active address. Runs on the popup page, which is
|
||||||
|
// the one moz-extension:// document the suite has open and therefore the only
|
||||||
|
// place the storage API is reachable from.
|
||||||
|
async function pointAtStubNode(d, rpcUrl) {
|
||||||
|
const outcome = await d.executeAsync(
|
||||||
|
`const done = arguments[arguments.length - 1];
|
||||||
|
const rpcUrl = arguments[0];
|
||||||
|
const api = typeof browser !== "undefined" ? browser : chrome;
|
||||||
|
Promise.resolve(api.storage.local.get("autistmask"))
|
||||||
|
.then((r) => {
|
||||||
|
const s = r.autistmask;
|
||||||
|
if (!s) throw new Error("the extension has no persisted state");
|
||||||
|
s.rpcUrl = rpcUrl;
|
||||||
|
const w = s.wallets && s.wallets[0];
|
||||||
|
const first = w && w.addresses && w.addresses[0];
|
||||||
|
const address = s.activeAddress || (first && first.address);
|
||||||
|
if (!address) throw new Error("the extension holds no address");
|
||||||
|
return Promise.resolve(api.storage.local.set({ autistmask: s }))
|
||||||
|
.then(() => done({ address: address }));
|
||||||
|
})
|
||||||
|
.catch((e) => done({ error: String((e && e.message) || e) }));`,
|
||||||
|
[rpcUrl],
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
outcome && !outcome.error,
|
||||||
|
"could not point the extension at the stub node: " +
|
||||||
|
(outcome && outcome.error),
|
||||||
|
);
|
||||||
|
return getAddress(outcome.address);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The approval window the background opened. Approvals are raised from an RPC
|
||||||
|
// call rather than from a user gesture, so the extension opens a real window
|
||||||
|
// for them, which is an ordinary window handle here.
|
||||||
|
async function waitForApprovalWindow(d, timeout = 30000) {
|
||||||
|
const deadline = Date.now() + timeout;
|
||||||
|
for (;;) {
|
||||||
|
const handle = await d.findWindow((u) => u.includes("?approval="));
|
||||||
|
if (handle) return handle;
|
||||||
|
if (Date.now() > deadline) {
|
||||||
|
throw new Error(
|
||||||
|
"the extension opened no approval window within " +
|
||||||
|
timeout +
|
||||||
|
"ms",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRequest(d, key, method, params) {
|
||||||
|
return d.execute(
|
||||||
|
"window.__dapp.start(arguments[0], arguments[1], arguments[2]);" +
|
||||||
|
" return true;",
|
||||||
|
[key, method, params],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The settled outcome of a parked request, or {settled:"pending"} if it is
|
||||||
|
// still outstanding. A bounded wait rather than a bare await: "returns a
|
||||||
|
// rejection rather than hanging" is one of the things under test, and an
|
||||||
|
// await would report a hang as a step timeout with no indication of which
|
||||||
|
// call never settled.
|
||||||
|
function settleRequest(d, key, timeout = 45000) {
|
||||||
|
return d.executeAsync(
|
||||||
|
`const done = arguments[arguments.length - 1];
|
||||||
|
const key = arguments[0];
|
||||||
|
const timeout = arguments[1];
|
||||||
|
Promise.race([
|
||||||
|
window.__dapp.settle(key),
|
||||||
|
new Promise((r) => setTimeout(() => r({ settled: "pending" }), timeout)),
|
||||||
|
]).then(done, (e) => done({ settled: "error", message: String(e) }));`,
|
||||||
|
[key, timeout],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every AUTISTMASK_* message that has crossed between the page and the
|
||||||
|
// content script. This is the boundary half of the rejection assertion: the
|
||||||
|
// code has to be on the wire as well as on the Error the page catches, so a
|
||||||
|
// pass cannot come from the provider inventing one.
|
||||||
|
function dappMessages(d, type) {
|
||||||
|
return d.execute(
|
||||||
|
// `want` is bound outside the callback deliberately: inside it,
|
||||||
|
// arguments[0] is the message being tested, not the script argument,
|
||||||
|
// and the filter silently matches nothing.
|
||||||
|
"var want = arguments[0];" +
|
||||||
|
" return window.__dapp.messages.filter(function (m) {" +
|
||||||
|
" return !want || m.type === want; });",
|
||||||
|
[type || null],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lastResponseError(d) {
|
||||||
|
const responses = await dappMessages(d, "AUTISTMASK_RESPONSE");
|
||||||
|
const last = responses[responses.length - 1];
|
||||||
|
assert(last, "the page received no AUTISTMASK_RESPONSE at all");
|
||||||
|
return last.error || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rejected prompt, asserted at both ends: the page's promise rejected
|
||||||
|
// rather than hanging or resolving, and the response that crossed the
|
||||||
|
// boundary carried EIP-1193 code 4001.
|
||||||
|
async function assertUserRejection(d, key, label) {
|
||||||
|
const outcome = await settleRequest(d, key);
|
||||||
|
assert(
|
||||||
|
outcome.settled !== "pending",
|
||||||
|
label + " never settled: the rejected prompt left the page hanging",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
outcome.settled === "rejected",
|
||||||
|
label + " resolved instead of rejecting: " + JSON.stringify(outcome),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
outcome.message === USER_REJECTION_MESSAGE,
|
||||||
|
label + " rejected with the wrong message: " + outcome.message,
|
||||||
|
);
|
||||||
|
const error = await lastResponseError(d);
|
||||||
|
assert(
|
||||||
|
error && error.code === 4001,
|
||||||
|
label +
|
||||||
|
" did not carry EIP-1193 code 4001 across the boundary: " +
|
||||||
|
JSON.stringify(error),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
outcome.hasCode,
|
||||||
|
label +
|
||||||
|
" reached the page as an error with no code property at all, so a " +
|
||||||
|
"dApp cannot tell the user's refusal from a failure: " +
|
||||||
|
JSON.stringify(outcome),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
outcome.code === 4001,
|
||||||
|
label +
|
||||||
|
" reached the page with code " +
|
||||||
|
JSON.stringify(outcome.code) +
|
||||||
|
" rather than EIP-1193 4001",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
outcome.name === "ProviderRpcError",
|
||||||
|
label +
|
||||||
|
" reached the page as " +
|
||||||
|
JSON.stringify(outcome.name) +
|
||||||
|
" rather than an EIP-1193 ProviderRpcError",
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
"# " +
|
||||||
|
label +
|
||||||
|
": code 4001 on the wire and on the page's " +
|
||||||
|
outcome.name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
step("the loopback dApp page gets the real inpage provider", async (env) => {
|
||||||
|
const d = env.driver;
|
||||||
|
|
||||||
|
// The popup is still the current window; point the extension at the stub
|
||||||
|
// node from there, then reload it so its in-memory copy of the state
|
||||||
|
// carries the new rpcUrl and cannot save the old one back over it.
|
||||||
|
env.address = await pointAtStubNode(d, env.server.rpcUrl);
|
||||||
|
await d.navigate(POPUP_URL);
|
||||||
|
await d.waitVisible("#view-main", STEP_TIMEOUT_MS);
|
||||||
|
env.popupWindow = await d.currentWindow();
|
||||||
|
|
||||||
|
env.dappWindow = await d.newWindow("tab");
|
||||||
|
await d.switchToWindow(env.dappWindow);
|
||||||
|
await d.navigate(env.server.url);
|
||||||
|
|
||||||
|
// window.ethereum is not the fixture's doing — it is the shipped content
|
||||||
|
// script, injected into a real http:// origin. Waiting for it is waiting
|
||||||
|
// for the real provider to have installed itself.
|
||||||
|
await d.waitFor(
|
||||||
|
"the injected EIP-1193 provider and the test page API",
|
||||||
|
"return !!window.ethereum && !!window.__dapp;",
|
||||||
|
[],
|
||||||
|
STEP_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
// EIP-6963, asked of the provider itself. The announcement carries the
|
||||||
|
// uuid src/content/index.js reads out of extension storage — call site 1
|
||||||
|
// in the issue — and it has to name this extension and hand back the very
|
||||||
|
// object on window.ethereum.
|
||||||
|
const announced = await d.executeAsync(
|
||||||
|
`const done = arguments[arguments.length - 1];
|
||||||
|
const onAnnounce = (e) => {
|
||||||
|
window.removeEventListener("eip6963:announceProvider", onAnnounce);
|
||||||
|
done({
|
||||||
|
rdns: e.detail.info.rdns,
|
||||||
|
uuid: e.detail.info.uuid,
|
||||||
|
isWindowEthereum: e.detail.provider === window.ethereum,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
window.addEventListener("eip6963:announceProvider", onAnnounce);
|
||||||
|
window.dispatchEvent(new Event("eip6963:requestProvider"));
|
||||||
|
setTimeout(() => done(null), 15000);`,
|
||||||
|
);
|
||||||
|
assert(announced, "the provider announced itself to no EIP-6963 request");
|
||||||
|
assert(
|
||||||
|
announced.rdns === "berlin.sneak.autistmask",
|
||||||
|
"the announced provider is not this extension: " +
|
||||||
|
JSON.stringify(announced),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
announced.isWindowEthereum,
|
||||||
|
"the announced provider is not the object on window.ethereum",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
typeof announced.uuid === "string" && announced.uuid.length === 36,
|
||||||
|
"the announcement carries no stored provider uuid: " +
|
||||||
|
JSON.stringify(announced.uuid),
|
||||||
|
);
|
||||||
|
|
||||||
|
// A full page -> content script -> background round trip that needs no
|
||||||
|
// approval, so the relay is proven before any prompt is driven. This is
|
||||||
|
// call site 2, the one that used to fail for every window.ethereum
|
||||||
|
// request a dApp made.
|
||||||
|
const chainId = await d.executeAsync(
|
||||||
|
`const done = arguments[arguments.length - 1];
|
||||||
|
window.ethereum.request({ method: "eth_chainId" }).then(
|
||||||
|
(r) => done({ ok: r }),
|
||||||
|
(e) => done({ err: String((e && e.message) || e) }),
|
||||||
|
);`,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
chainId && chainId.ok === "0x1",
|
||||||
|
"eth_chainId did not round trip through the extension: " +
|
||||||
|
JSON.stringify(chainId),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
"# dapp origin " + env.server.origin + " active address " + env.address,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
step(
|
||||||
|
"eth_requestAccounts approved returns the selected address",
|
||||||
|
async (env) => {
|
||||||
|
const d = env.driver;
|
||||||
|
await d.switchToWindow(env.dappWindow);
|
||||||
|
await startRequest(d, "accounts", "eth_requestAccounts", []);
|
||||||
|
|
||||||
|
const popup = await waitForApprovalWindow(d);
|
||||||
|
await d.switchToWindow(popup);
|
||||||
|
await d.waitVisible("#view-approve-site");
|
||||||
|
|
||||||
|
const hostname = await d.text("#approve-hostname");
|
||||||
|
assert(
|
||||||
|
hostname === "127.0.0.1",
|
||||||
|
"the site prompt names the wrong origin: " +
|
||||||
|
JSON.stringify(hostname),
|
||||||
|
);
|
||||||
|
const shown = await d.text("#approve-address");
|
||||||
|
assert(
|
||||||
|
shown.toLowerCase().includes(env.address.toLowerCase()),
|
||||||
|
"the site prompt shows the wrong address: " + JSON.stringify(shown),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remembered, so the origin stays authorized for the sign and transaction
|
||||||
|
// steps below.
|
||||||
|
const checked = await d.execute(
|
||||||
|
'return document.getElementById("approve-remember").checked;',
|
||||||
|
);
|
||||||
|
if (!checked) await d.click("#approve-remember");
|
||||||
|
await d.click("#btn-approve");
|
||||||
|
|
||||||
|
// The approve button closes its own window, so get off it before asking
|
||||||
|
// the page anything.
|
||||||
|
await d.switchToWindow(env.dappWindow);
|
||||||
|
const outcome = await settleRequest(d, "accounts");
|
||||||
|
assert(
|
||||||
|
outcome.settled === "resolved",
|
||||||
|
"eth_requestAccounts did not resolve: " + JSON.stringify(outcome),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
Array.isArray(outcome.result) && outcome.result.length === 1,
|
||||||
|
"eth_requestAccounts returned no single account: " +
|
||||||
|
JSON.stringify(outcome.result),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
getAddress(outcome.result[0]) === env.address,
|
||||||
|
"eth_requestAccounts returned " +
|
||||||
|
outcome.result[0] +
|
||||||
|
", not the selected address " +
|
||||||
|
env.address,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
step(
|
||||||
|
"personal_sign returns a signature that recovers to the address",
|
||||||
|
async (env) => {
|
||||||
|
const d = env.driver;
|
||||||
|
await d.switchToWindow(env.dappWindow);
|
||||||
|
await startRequest(d, "sign", "personal_sign", [SIGN_HEX, env.address]);
|
||||||
|
|
||||||
|
const popup = await waitForApprovalWindow(d);
|
||||||
|
await d.switchToWindow(popup);
|
||||||
|
await d.waitVisible("#view-approve-sign");
|
||||||
|
|
||||||
|
const screen = await d.execute(
|
||||||
|
`return {
|
||||||
|
hostname: document.getElementById("approve-sign-hostname").textContent,
|
||||||
|
type: document.getElementById("approve-sign-type").textContent,
|
||||||
|
message: document.getElementById("approve-sign-message").textContent,
|
||||||
|
from: document.getElementById("approve-sign-from").textContent,
|
||||||
|
};`,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
screen.hostname === "127.0.0.1",
|
||||||
|
"the sign prompt names the wrong origin: " +
|
||||||
|
JSON.stringify(screen.hostname),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
screen.type === "Personal message",
|
||||||
|
"the sign prompt reports the wrong type: " +
|
||||||
|
JSON.stringify(screen.type),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
screen.message === SIGN_TEXT,
|
||||||
|
"the sign prompt shows the wrong message: " +
|
||||||
|
JSON.stringify(screen.message),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
screen.from.toLowerCase().includes(env.address.toLowerCase()),
|
||||||
|
"the sign prompt shows the wrong signing address: " +
|
||||||
|
JSON.stringify(screen.from),
|
||||||
|
);
|
||||||
|
|
||||||
|
await d.fill("#approve-sign-password", PASSWORD);
|
||||||
|
await d.click("#btn-approve-sign");
|
||||||
|
|
||||||
|
await d.switchToWindow(env.dappWindow);
|
||||||
|
const outcome = await settleRequest(d, "sign");
|
||||||
|
assert(
|
||||||
|
outcome.settled === "resolved",
|
||||||
|
"personal_sign did not resolve: " + JSON.stringify(outcome),
|
||||||
|
);
|
||||||
|
const recovered = getAddress(
|
||||||
|
verifyMessage(getBytes(SIGN_HEX), outcome.result),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
"# personal_sign: recovered=" +
|
||||||
|
recovered +
|
||||||
|
" expected=" +
|
||||||
|
env.address,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
recovered === env.address,
|
||||||
|
"the personal_sign signature recovers to " +
|
||||||
|
recovered +
|
||||||
|
", not to the approved address " +
|
||||||
|
env.address,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
step(
|
||||||
|
"eth_sendTransaction shows the transaction and returns its hash",
|
||||||
|
async (env) => {
|
||||||
|
const d = env.driver;
|
||||||
|
const before = env.server.broadcast.length;
|
||||||
|
|
||||||
|
await d.switchToWindow(env.dappWindow);
|
||||||
|
await startRequest(d, "tx", "eth_sendTransaction", [
|
||||||
|
{
|
||||||
|
from: env.address,
|
||||||
|
to: STUB_COUNTERPARTY,
|
||||||
|
value: toQuantity(TX_VALUE_WEI),
|
||||||
|
data: TX_DATA,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const popup = await waitForApprovalWindow(d);
|
||||||
|
await d.switchToWindow(popup);
|
||||||
|
await d.waitVisible("#view-approve-tx");
|
||||||
|
|
||||||
|
const screen = await d.execute(
|
||||||
|
`return {
|
||||||
|
hostname: document.getElementById("approve-tx-hostname").textContent,
|
||||||
|
from: document.getElementById("approve-tx-from").textContent,
|
||||||
|
to: document.getElementById("approve-tx-to").textContent,
|
||||||
|
value: document.getElementById("approve-tx-value").textContent,
|
||||||
|
data: document.getElementById("approve-tx-data").textContent,
|
||||||
|
dataShown: !document
|
||||||
|
.getElementById("approve-tx-data-section")
|
||||||
|
.classList.contains("hidden"),
|
||||||
|
};`,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
screen.hostname === "127.0.0.1",
|
||||||
|
"the transaction prompt names the wrong origin: " +
|
||||||
|
JSON.stringify(screen.hostname),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
screen.from.toLowerCase().includes(env.address.toLowerCase()),
|
||||||
|
"the transaction prompt shows the wrong sender: " +
|
||||||
|
JSON.stringify(screen.from),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
screen.to.toLowerCase().includes(STUB_COUNTERPARTY.toLowerCase()),
|
||||||
|
"the transaction prompt shows the wrong recipient: " +
|
||||||
|
JSON.stringify(screen.to),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
screen.value.startsWith(TX_VALUE_ETH + " ETH"),
|
||||||
|
"the transaction prompt shows the wrong value: " +
|
||||||
|
JSON.stringify(screen.value),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
screen.dataShown && screen.data === TX_DATA,
|
||||||
|
"the transaction prompt does not show the approved call data: " +
|
||||||
|
JSON.stringify(screen.data),
|
||||||
|
);
|
||||||
|
|
||||||
|
await d.fill("#approve-tx-password", PASSWORD);
|
||||||
|
await d.click("#btn-approve-tx");
|
||||||
|
|
||||||
|
// The approval window hands off to the wait screen rather than closing,
|
||||||
|
// and the hash it shows is asserted before it is retired: left open it
|
||||||
|
// polls the stub node for a receipt for the rest of the run.
|
||||||
|
await d.waitVisible("#view-wait-tx", STEP_TIMEOUT_MS);
|
||||||
|
const waitHash = await d.text("#wait-tx-hash");
|
||||||
|
|
||||||
|
await d.switchToWindow(env.dappWindow);
|
||||||
|
const outcome = await settleRequest(d, "tx");
|
||||||
|
assert(
|
||||||
|
outcome.settled === "resolved",
|
||||||
|
"eth_sendTransaction did not resolve: " + JSON.stringify(outcome),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The artifact as the node saw it, not as the extension described it.
|
||||||
|
assert(
|
||||||
|
env.server.broadcast.length === before + 1,
|
||||||
|
"expected exactly one raw transaction to reach the node, got " +
|
||||||
|
(env.server.broadcast.length - before),
|
||||||
|
);
|
||||||
|
const signed = Transaction.from(
|
||||||
|
env.server.broadcast[env.server.broadcast.length - 1],
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
"# eth_sendTransaction: signer=" +
|
||||||
|
getAddress(signed.from) +
|
||||||
|
" to=" +
|
||||||
|
getAddress(signed.to) +
|
||||||
|
" value=" +
|
||||||
|
formatEther(signed.value) +
|
||||||
|
" chainId=" +
|
||||||
|
signed.chainId,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
getAddress(signed.from) === env.address,
|
||||||
|
"the broadcast transaction was signed by " +
|
||||||
|
getAddress(signed.from) +
|
||||||
|
", not by the approved address " +
|
||||||
|
env.address,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
getAddress(signed.to) === getAddress(STUB_COUNTERPARTY),
|
||||||
|
"the broadcast transaction goes to " + signed.to,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
signed.value === TX_VALUE_WEI,
|
||||||
|
"the broadcast transaction carries " +
|
||||||
|
formatEther(signed.value) +
|
||||||
|
" ETH, not the approved " +
|
||||||
|
TX_VALUE_ETH,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
signed.data === TX_DATA,
|
||||||
|
"the broadcast transaction carries different call data: " +
|
||||||
|
signed.data,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
signed.chainId === 1n,
|
||||||
|
"the broadcast transaction is for chain " + signed.chainId,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
outcome.result === signed.hash,
|
||||||
|
"the page received " +
|
||||||
|
outcome.result +
|
||||||
|
", not the hash of the broadcast transaction " +
|
||||||
|
signed.hash,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
waitHash.includes(signed.hash),
|
||||||
|
"the wait screen shows a different hash: " +
|
||||||
|
JSON.stringify(waitHash),
|
||||||
|
);
|
||||||
|
|
||||||
|
await d.switchToWindow(popup);
|
||||||
|
await d.closeWindow(env.dappWindow);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
step(
|
||||||
|
"closing an approval window rejects the request with 4001",
|
||||||
|
async (env) => {
|
||||||
|
const d = env.driver;
|
||||||
|
const before = env.server.broadcast.length;
|
||||||
|
|
||||||
|
await d.switchToWindow(env.dappWindow);
|
||||||
|
await startRequest(d, "sign-closed", "personal_sign", [
|
||||||
|
SIGN_HEX,
|
||||||
|
env.address,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const popup = await waitForApprovalWindow(d);
|
||||||
|
await d.switchToWindow(popup);
|
||||||
|
await d.waitVisible("#view-approve-sign");
|
||||||
|
|
||||||
|
// Closed, not rejected: this is the windows.onRemoved path, which can
|
||||||
|
// only fire if windows.create() handed back a window id for the
|
||||||
|
// approval to be matched against — call site 4 in the issue, and the
|
||||||
|
// reason suppressing that write-back turns this step red.
|
||||||
|
await d.closeWindow(env.dappWindow);
|
||||||
|
|
||||||
|
await assertUserRejection(d, "sign-closed", "a closed approval window");
|
||||||
|
assert(
|
||||||
|
env.server.broadcast.length === before,
|
||||||
|
"a closed approval window still put a transaction on the node",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// ------------------------------------------------------------- runner
|
// ------------------------------------------------------------- runner
|
||||||
|
|
||||||
|
// Uncaught extension errors that are known, tracked and deliberately
|
||||||
|
// tolerated, in the same spirit as ALLOWED_ERRORS in tests/e2e/harness.js:
|
||||||
|
// every entry names the issue that will delete it, and every occurrence is
|
||||||
|
// still printed, so tolerating one is visible in the log rather than silent.
|
||||||
|
// This is the only concession in an otherwise zero-tolerance policy.
|
||||||
|
const ALLOWED_ERRORS = [
|
||||||
|
{
|
||||||
|
// The site-connection buttons in src/popup/views/approval.js send
|
||||||
|
// their decision and call window.close() on the next line. Firefox's
|
||||||
|
// BaseContext.wrapPromise reports, through Cu.reportError, any
|
||||||
|
// extension-API promise that settles after its context unloaded —
|
||||||
|
// whether or not the caller attached a handler, so notify()'s catch
|
||||||
|
// cannot suppress it.
|
||||||
|
//
|
||||||
|
// Pre-existing, and not introduced by the promise shim: the send was
|
||||||
|
// already unawaited, and this suite is merely the first thing to
|
||||||
|
// drive that window on Firefox. It is the same teardown ordering as
|
||||||
|
// the issue below, whose fix — making the outcome independent of when
|
||||||
|
// the popup closes — removes this entry with it.
|
||||||
|
pattern: /Promise (?:resolved|rejected) after context unloaded/,
|
||||||
|
source: /\/src\/popup\/index\.js$/,
|
||||||
|
issue: "https://git.eeqj.de/sneak/AutistMask/issues/275",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function allowedFor(e) {
|
||||||
|
return ALLOWED_ERRORS.find(
|
||||||
|
(a) => a.pattern.test(e.msg) && a.source.test(e.src),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function formatError(e) {
|
function formatError(e) {
|
||||||
return (
|
return (
|
||||||
e.msg + " (" + e.src + ":" + e.line + (e.cat ? ", " + e.cat : "") + ")"
|
e.msg + " (" + e.src + ":" + e.line + (e.cat ? ", " + e.cat : "") + ")"
|
||||||
@@ -165,6 +776,21 @@ async function main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Loopback survives --network none, so this is the http:// origin the
|
||||||
|
// dApp steps need and the node they talk to. Started before the browser
|
||||||
|
// so its url is available to the first step that asks for it.
|
||||||
|
let server;
|
||||||
|
try {
|
||||||
|
server = await startDappServer();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
"e2e-firefox: cannot serve the dApp fixture: " + e.message,
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log("# dapp fixture: " + server.url + " rpc " + server.rpcUrl);
|
||||||
|
|
||||||
let driver;
|
let driver;
|
||||||
try {
|
try {
|
||||||
driver = await start();
|
driver = await start();
|
||||||
@@ -175,12 +801,20 @@ async function main() {
|
|||||||
// absent suite. Never skip and report success.
|
// absent suite. Never skip and report success.
|
||||||
console.error("e2e-firefox: cannot run the suite: " + e.message);
|
console.error("e2e-firefox: cannot run the suite: " + e.message);
|
||||||
if (driver) await driver.quit().catch(() => {});
|
if (driver) await driver.quit().catch(() => {});
|
||||||
|
await server.close();
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const errors = new ConsoleErrors(driver, EXTENSION_ORIGIN);
|
const errors = new ConsoleErrors(driver, EXTENSION_ORIGIN);
|
||||||
const env = { driver, phrase: null };
|
const env = {
|
||||||
|
driver,
|
||||||
|
server,
|
||||||
|
phrase: null,
|
||||||
|
address: null,
|
||||||
|
dappWindow: null,
|
||||||
|
popupWindow: null,
|
||||||
|
};
|
||||||
|
|
||||||
console.log("# extension origin: " + EXTENSION_ORIGIN);
|
console.log("# extension origin: " + EXTENSION_ORIGIN);
|
||||||
console.log("1.." + steps.length);
|
console.log("1.." + steps.length);
|
||||||
@@ -232,6 +866,20 @@ async function main() {
|
|||||||
installFailure = null;
|
installFailure = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tolerated errors are set aside, never dropped: each one is
|
||||||
|
// printed with the issue that keeps it on the list, so the
|
||||||
|
// concession stays in the run output.
|
||||||
|
const tolerated = found.filter((e) => allowedFor(e));
|
||||||
|
found = found.filter((e) => !allowedFor(e));
|
||||||
|
for (const e of tolerated) {
|
||||||
|
console.log(
|
||||||
|
"# tolerated (" +
|
||||||
|
allowedFor(e).issue +
|
||||||
|
"): " +
|
||||||
|
formatError(e),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Any uncaught error from an extension source fails the step
|
// Any uncaught error from an extension source fails the step
|
||||||
// that provoked it, whether or not its assertions passed.
|
// that provoked it, whether or not its assertions passed.
|
||||||
if (!failure && found.length > 0) {
|
if (!failure && found.length > 0) {
|
||||||
@@ -256,7 +904,13 @@ async function main() {
|
|||||||
// blamed on any one step, but they are still reported and they
|
// blamed on any one step, but they are still reported and they
|
||||||
// still fail the run.
|
// still fail the run.
|
||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
const trailing = await errors.take();
|
const trailingAll = await errors.take();
|
||||||
|
for (const e of trailingAll.filter((x) => allowedFor(x))) {
|
||||||
|
console.log(
|
||||||
|
"# tolerated (" + allowedFor(e).issue + "): " + formatError(e),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const trailing = trailingAll.filter((e) => !allowedFor(e));
|
||||||
console.log(
|
console.log(
|
||||||
"# " +
|
"# " +
|
||||||
(steps.length - failed) +
|
(steps.length - failed) +
|
||||||
@@ -273,12 +927,25 @@ async function main() {
|
|||||||
);
|
);
|
||||||
for (const e of trailing) console.log("# " + formatError(e));
|
for (const e of trailing) console.log("# " + formatError(e));
|
||||||
}
|
}
|
||||||
|
// A JSON-RPC method nothing answered means the extension asked the
|
||||||
|
// node something this fixture does not model, and whatever depended
|
||||||
|
// on the answer took the error branch instead. That is a hole in the
|
||||||
|
// fixture, not a pass.
|
||||||
|
if (server.unstubbed.length > 0) {
|
||||||
|
console.log(
|
||||||
|
"# FAILED: no fixture for JSON-RPC method(s) " +
|
||||||
|
[...new Set(server.unstubbed)].join(", "),
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
if (failed > 0 || trailing.length > 0) {
|
if (failed > 0 || trailing.length > 0) {
|
||||||
console.log("# FAILED");
|
console.log("# FAILED");
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await driver.quit().catch(() => {});
|
await driver.quit().catch(() => {});
|
||||||
|
await server.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const os = require("os");
|
|||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
||||||
const { chromium } = require("playwright-core");
|
const { chromium } = require("playwright-core");
|
||||||
const { installNetworkStubs } = require("./network");
|
const { installNetworkStubs, WORKER_PROBE_URL } = require("./network");
|
||||||
|
|
||||||
const REPO_ROOT = path.resolve(__dirname, "..", "..");
|
const REPO_ROOT = path.resolve(__dirname, "..", "..");
|
||||||
const EXT_PATH = path.join(REPO_ROOT, "dist", "chrome");
|
const EXT_PATH = path.join(REPO_ROOT, "dist", "chrome");
|
||||||
@@ -129,42 +129,109 @@ function attachErrorListeners(ctx, errors) {
|
|||||||
// if it ever stops being.
|
// if it ever stops being.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The most recently seen background worker, waiting for one if none has
|
||||||
|
// appeared yet. Most recent rather than first: Chrome stops an idle MV3
|
||||||
|
// worker and starts a fresh one on the next event, and a handle to a
|
||||||
|
// stopped worker cannot be evaluated in.
|
||||||
async function serviceWorker(ctx) {
|
async function serviceWorker(ctx) {
|
||||||
const [existing] = ctx.serviceWorkers();
|
const workers = ctx.serviceWorkers();
|
||||||
if (existing) return existing;
|
const latest = workers[workers.length - 1];
|
||||||
|
if (latest) return latest;
|
||||||
return ctx.waitForEvent("serviceworker", { timeout: 30000 });
|
return ctx.waitForEvent("serviceworker", { timeout: 30000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// How long to wait for the background worker's first outbound request.
|
// How long to wait for the probe request the worker is asked to make.
|
||||||
//
|
|
||||||
// The margin that actually decides whether this check is sound is not
|
|
||||||
// this timeout — it is whether the route handler is installed before the
|
|
||||||
// worker fetches. Measured over several runs: route installation
|
|
||||||
// completes 11-23ms after the context comes up, and the worker's
|
|
||||||
// blocklist fetch arrives 525-883ms after that, so the route wins by
|
|
||||||
// roughly 25-50x. This 30s figure is only slack for a loaded machine on
|
|
||||||
// top of that; losing the race fails the run rather than passing it
|
|
||||||
// quietly, which was verified by forcing a 3s delay before route
|
|
||||||
// installation.
|
|
||||||
const WORKER_TRAFFIC_TIMEOUT_MS = 30000;
|
const WORKER_TRAFFIC_TIMEOUT_MS = 30000;
|
||||||
|
|
||||||
// ctx.route() only sees service-worker requests when Playwright runs with
|
// ctx.route() only sees service-worker requests when Playwright runs with
|
||||||
// PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1, which script/test-e2e
|
// PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1, which script/test-e2e
|
||||||
// sets. Without it the worker's traffic — notably the phishing blocklist
|
// sets. Without it every fetch the background worker makes goes to the
|
||||||
// fetch src/background/index.js issues at startup — goes to the real
|
// real internet and nothing says so. A harness whose isolation can lapse
|
||||||
// internet, and nothing says so, because src/shared/phishingDomains.js
|
// in silence is worthless, so this does not take the flag on trust: a
|
||||||
// swallows fetch failures. A harness whose isolation can lapse in silence
|
// request the worker itself issues has to show up in the route handler,
|
||||||
// is worthless, so this does not take the flag on trust: the background
|
// or the suite refuses to run.
|
||||||
// worker's own startup fetch has to show up in the route handler, or the
|
|
||||||
// suite refuses to run.
|
|
||||||
//
|
//
|
||||||
// Deliberately NOT a synthetic probe fetched through worker.evaluate():
|
// The anchor is a probe the harness asks the worker for, not traffic the
|
||||||
// evaluating in an extension worker this early kills it (the call fails
|
// extension generates on its own. It used to be the phishing blocklist
|
||||||
// with "Target page, context or browser has been closed" and the worker
|
// fetch src/background/index.js issued at startup; that fetch is gone —
|
||||||
// disappears), which would break the very thing being measured. Observing
|
// the blocklist is vendored at build time and the extension contacts
|
||||||
// traffic the extension already generates costs nothing and cannot
|
// nobody when it starts — so there is no longer any startup traffic to
|
||||||
// perturb it.
|
// observe and the check generates its own.
|
||||||
async function assertWorkerTrafficIntercepted(stubs) {
|
//
|
||||||
|
// Evaluating in the worker straight after launch does not work, and that
|
||||||
|
// is not a stale observation: it was tried again here and failed with
|
||||||
|
// "Target page, context or browser has been closed" on the first run.
|
||||||
|
// Chrome stops the freshly registered worker as soon as it has nothing to
|
||||||
|
// do, and the extension no longer gives it anything to do — which is the
|
||||||
|
// same change that removed the old anchor. So the probe wakes the worker
|
||||||
|
// before it evaluates in it, by sending it a message from an extension
|
||||||
|
// page and waiting for the reply: delivering a message is what starts a
|
||||||
|
// stopped worker, and a worker that has just answered one is alive.
|
||||||
|
// The evaluated fetch is not awaited, so nothing in the worker is held
|
||||||
|
// open by the probe either.
|
||||||
|
async function wakeWorker(ctx) {
|
||||||
|
const sw = await serviceWorker(ctx);
|
||||||
|
const extensionId = new URL(sw.url()).host;
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
try {
|
||||||
|
await page.goto(
|
||||||
|
"chrome-extension://" + extensionId + "/src/popup/index.html",
|
||||||
|
);
|
||||||
|
// eth_chainId is answered from local state: it wakes the worker
|
||||||
|
// and changes nothing.
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
chrome.runtime.sendMessage(
|
||||||
|
{
|
||||||
|
type: "AUTISTMASK_RPC",
|
||||||
|
method: "eth_chainId",
|
||||||
|
params: [],
|
||||||
|
},
|
||||||
|
() => resolve(null),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeFromWorker(ctx, url) {
|
||||||
|
let lastError = null;
|
||||||
|
for (let attempt = 0; attempt < 5; attempt++) {
|
||||||
|
try {
|
||||||
|
await wakeWorker(ctx);
|
||||||
|
const sw = await serviceWorker(ctx);
|
||||||
|
await sw.evaluate((u) => {
|
||||||
|
// Deliberately not awaited and never rejected: what is
|
||||||
|
// being observed is that the request reaches the route
|
||||||
|
// handler, and an unhandled rejection in the worker would
|
||||||
|
// be collected as a suite error if it did not.
|
||||||
|
fetch(u).catch(() => {});
|
||||||
|
}, url);
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
lastError = e;
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
"could not ask the background worker to fetch " +
|
||||||
|
url +
|
||||||
|
", so service-worker interception was never tested. Last " +
|
||||||
|
"error: " +
|
||||||
|
(lastError && lastError.message),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertWorkerTrafficIntercepted(ctx, stubs) {
|
||||||
|
await probeFromWorker(ctx, WORKER_PROBE_URL);
|
||||||
|
|
||||||
const seen = await stubs.waitForServiceWorkerTraffic(
|
const seen = await stubs.waitForServiceWorkerTraffic(
|
||||||
WORKER_TRAFFIC_TIMEOUT_MS,
|
WORKER_TRAFFIC_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
@@ -177,19 +244,16 @@ async function assertWorkerTrafficIntercepted(stubs) {
|
|||||||
throw new Error(
|
throw new Error(
|
||||||
"observed no service-worker request in the route handler within " +
|
"observed no service-worker request in the route handler within " +
|
||||||
WORKER_TRAFFIC_TIMEOUT_MS +
|
WORKER_TRAFFIC_TIMEOUT_MS +
|
||||||
"ms. Under working interception the background worker's " +
|
"ms, although the background worker was asked to fetch " +
|
||||||
"startup blocklist fetch (src/background/index.js) reaches the " +
|
WORKER_PROBE_URL +
|
||||||
"handler about half a second after the route is installed. " +
|
". Two causes are plausible and this check cannot distinguish " +
|
||||||
"Two causes are plausible and this check cannot distinguish " +
|
|
||||||
"them: (1) service-worker interception is not in effect, so " +
|
"them: (1) service-worker interception is not in effect, so " +
|
||||||
"that traffic went to the real internet unobserved — the suite " +
|
"that request went to the real internet unobserved — the suite " +
|
||||||
"must be run through script/test-e2e, which sets " +
|
"must be run through script/test-e2e, which sets " +
|
||||||
"PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1, and a " +
|
"PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1, and a " +
|
||||||
"Playwright upgrade may have dropped or renamed that flag; " +
|
"Playwright upgrade may have dropped or renamed that flag; " +
|
||||||
"(2) no worker request was made in the first place — the route " +
|
"(2) the probe never ran, because the worker was torn down " +
|
||||||
"lost the startup race, or the worker no longer fetches at " +
|
"between being handed over and being evaluated in. Either way " +
|
||||||
"startup, in which case this check needs a new anchor because " +
|
|
||||||
"there is no longer any worker traffic to observe. Either way " +
|
|
||||||
"the fix is a replacement mechanism or an honest downgrade of " +
|
"the fix is a replacement mechanism or an honest downgrade of " +
|
||||||
"the isolation claims in tests/e2e/network.js and README.md — " +
|
"the isolation claims in tests/e2e/network.js and README.md — " +
|
||||||
"not deleting this check",
|
"not deleting this check",
|
||||||
@@ -241,7 +305,7 @@ async function launch(routeOpts) {
|
|||||||
routeOpts.report = (text) => errors.record("network", text);
|
routeOpts.report = (text) => errors.record("network", text);
|
||||||
const stubs = await installNetworkStubs(ctx, routeOpts);
|
const stubs = await installNetworkStubs(ctx, routeOpts);
|
||||||
|
|
||||||
await assertWorkerTrafficIntercepted(stubs);
|
await assertWorkerTrafficIntercepted(ctx, stubs);
|
||||||
|
|
||||||
// The extension id is derived from the unpacked path, so it
|
// The extension id is derived from the unpacked path, so it
|
||||||
// changes and must never be hardcoded. It is the host part of the
|
// changes and must never be hardcoded. It is the host part of the
|
||||||
@@ -287,7 +351,7 @@ async function pageCompilesWasm(page) {
|
|||||||
try {
|
try {
|
||||||
await WebAssembly.compile(new Uint8Array(bytes));
|
await WebAssembly.compile(new Uint8Array(bytes));
|
||||||
return true;
|
return true;
|
||||||
} catch (_) {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, EMPTY_WASM_MODULE);
|
}, EMPTY_WASM_MODULE);
|
||||||
|
|||||||
@@ -9,13 +9,12 @@
|
|||||||
//
|
//
|
||||||
// Service-worker coverage is not free: ctx.route() only sees worker
|
// Service-worker coverage is not free: ctx.route() only sees worker
|
||||||
// traffic when PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1 is set in
|
// traffic when PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1 is set in
|
||||||
// the environment, which script/test-e2e does. Without it the phishing
|
// the environment, which script/test-e2e does. Without it every fetch the
|
||||||
// blocklist fetch that src/background/index.js issues at worker startup
|
// MV3 background worker makes — the JSON-RPC calls behind every approval
|
||||||
// silently reaches raw.githubusercontent.com on the open internet, and
|
// in this suite among them — goes to the real internet unobserved. That is
|
||||||
// src/shared/phishingDomains.js swallows the failure so nothing surfaces
|
// not left to trust: waitForServiceWorkerTraffic() below backs the
|
||||||
// it. That is not left to trust: waitForServiceWorkerTraffic() below
|
// launch-time canary in harness.js, which fails the entire suite if worker
|
||||||
// backs the launch-time canary in harness.js, which fails the entire
|
// requests stop being visible here.
|
||||||
// suite if worker requests stop being visible here.
|
|
||||||
//
|
//
|
||||||
// Anything not explicitly stubbed here is aborted AND reported to the
|
// Anything not explicitly stubbed here is aborted AND reported to the
|
||||||
// error collector, so a newly added outbound call shows up as a test
|
// error collector, so a newly added outbound call shows up as a test
|
||||||
@@ -93,9 +92,32 @@ function word(value) {
|
|||||||
// is put there by the shipped manifest's MAIN-world content script, exactly
|
// is put there by the shipped manifest's MAIN-world content script, exactly
|
||||||
// as it is on any http(s) page a user visits, so what these tests speak to
|
// as it is on any http(s) page a user visits, so what these tests speak to
|
||||||
// is the real inpage provider and not a copy the harness wired up.
|
// is the real inpage provider and not a copy the harness wired up.
|
||||||
|
//
|
||||||
|
// DAPP_HTML below is exported and served verbatim by the Firefox suite too
|
||||||
|
// (tests/e2e/firefox/dapp.js), from a loopback origin rather than through a
|
||||||
|
// route handler. The two suites drive different browsers over different
|
||||||
|
// protocols, but the page they drive — the __dapp API, the message log — is
|
||||||
|
// one fixture, so an assertion written against it means the same thing on
|
||||||
|
// both.
|
||||||
const DAPP_ORIGIN = "https://dapp.e2e.test";
|
const DAPP_ORIGIN = "https://dapp.e2e.test";
|
||||||
const DAPP_URL = DAPP_ORIGIN + "/";
|
const DAPP_URL = DAPP_ORIGIN + "/";
|
||||||
|
|
||||||
|
// The same page, served from a hostname that is on the vendored phishing
|
||||||
|
// blocklist, so the phishing warning can be driven end to end against the real
|
||||||
|
// list rather than a stub of it. It is a live entry at the pinned upstream
|
||||||
|
// commit; upstream prunes, so a re-vendoring run that retires it turns the
|
||||||
|
// phishing test red, and the fix is a current entry, not a weaker assertion.
|
||||||
|
const PHISHING_DAPP_ORIGIN = "https://myetheywallet.com";
|
||||||
|
const PHISHING_DAPP_URL = PHISHING_DAPP_ORIGIN + "/";
|
||||||
|
|
||||||
|
// A request the harness asks the background service worker to make, purely so
|
||||||
|
// that worker interception can be proved before any test runs. Nothing in the
|
||||||
|
// extension fetches at startup any more — the blocklist is vendored at build
|
||||||
|
// time — so the canary in harness.js has no product traffic to anchor on and
|
||||||
|
// generates its own. See assertWorkerTrafficIntercepted().
|
||||||
|
const WORKER_PROBE_ORIGIN = "https://worker-probe.e2e.test";
|
||||||
|
const WORKER_PROBE_URL = WORKER_PROBE_ORIGIN + "/canary";
|
||||||
|
|
||||||
// Requests are parked rather than awaited. An approval prompt only exists
|
// Requests are parked rather than awaited. An approval prompt only exists
|
||||||
// while its call is in flight, so a test that awaited the promise could
|
// while its call is in flight, so a test that awaited the promise could
|
||||||
// never drive the popup that has to settle it; start() files the promise
|
// never drive the popup that has to settle it; start() files the promise
|
||||||
@@ -548,10 +570,9 @@ async function installNetworkStubs(ctx, opts) {
|
|||||||
// E2E_TRACE_NETWORK=1 prints every request that reaches this handler,
|
// E2E_TRACE_NETWORK=1 prints every request that reaches this handler,
|
||||||
// tagged [sw] when it originated in the background service worker.
|
// tagged [sw] when it originated in the background service worker.
|
||||||
// It exists so the isolation claim above can be re-checked by anyone
|
// It exists so the isolation claim above can be re-checked by anyone
|
||||||
// in one command, without editing files: the phishing blocklist fetch
|
// in one command, without editing files: the canary probe and then
|
||||||
// showing up with an [sw] tag is the proof that the worker really is
|
// every JSON-RPC call behind an approval showing up with an [sw] tag
|
||||||
// intercepted and that the raw.githubusercontent.com stub below is
|
// is the proof that the worker really is intercepted.
|
||||||
// live code rather than decoration.
|
|
||||||
const trace = traceEnabled(process.env.E2E_TRACE_NETWORK);
|
const trace = traceEnabled(process.env.E2E_TRACE_NETWORK);
|
||||||
|
|
||||||
// Regex rather than a glob so chrome-extension:// resource loads are
|
// Regex rather than a glob so chrome-extension:// resource loads are
|
||||||
@@ -582,7 +603,11 @@ async function installNetworkStubs(ctx, opts) {
|
|||||||
// trips run against a real http(s) origin — which is what makes the
|
// trips run against a real http(s) origin — which is what makes the
|
||||||
// shipped content scripts inject at all — without any remote origin
|
// shipped content scripts inject at all — without any remote origin
|
||||||
// being involved.
|
// being involved.
|
||||||
if (url.origin === DAPP_ORIGIN && p === "/") {
|
if (
|
||||||
|
(url.origin === DAPP_ORIGIN ||
|
||||||
|
url.origin === PHISHING_DAPP_ORIGIN) &&
|
||||||
|
p === "/"
|
||||||
|
) {
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "text/html; charset=utf-8",
|
contentType: "text/html; charset=utf-8",
|
||||||
@@ -628,18 +653,10 @@ async function installNetworkStubs(ctx, opts) {
|
|||||||
return jsonResponse(route, { Data: {} });
|
return jsonResponse(route, { Data: {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
// MetaMask phishing blocklist
|
// The interception canary's own request. Answered with nothing: what
|
||||||
if (
|
// is being observed is that it arrived here at all.
|
||||||
url.hostname === "raw.githubusercontent.com" ||
|
if (url.href === WORKER_PROBE_URL) {
|
||||||
p.endsWith("/eth-phishing-detect/main/src/config.json")
|
return route.fulfill({ status: 204, body: "" });
|
||||||
) {
|
|
||||||
return jsonResponse(route, {
|
|
||||||
version: 2,
|
|
||||||
tolerance: 2,
|
|
||||||
fuzzylist: [],
|
|
||||||
whitelist: [],
|
|
||||||
blacklist: [],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Best-effort Etherscan address labels: served as an empty page.
|
// Best-effort Etherscan address labels: served as an empty page.
|
||||||
@@ -660,12 +677,12 @@ async function installNetworkStubs(ctx, opts) {
|
|||||||
* Resolve with the first service-worker-originated request this
|
* Resolve with the first service-worker-originated request this
|
||||||
* handler saw, or null if none arrives within `ms`.
|
* handler saw, or null if none arrives within `ms`.
|
||||||
*
|
*
|
||||||
* The background worker fetches the phishing blocklist at
|
* The caller asks the worker for one request of its own (see
|
||||||
* startup, unconditionally, within about a second of the context
|
* WORKER_PROBE_URL) and then waits here, so under working
|
||||||
* coming up — so under working interception this resolves almost
|
* interception this resolves almost immediately. Nothing arriving
|
||||||
* immediately. Nothing arriving means worker traffic is bypassing
|
* means worker traffic is bypassing the handler entirely and going
|
||||||
* the handler entirely and going to the real internet, which the
|
* to the real internet, which the caller turns into a hard failure
|
||||||
* caller turns into a hard failure of the whole suite.
|
* of the whole suite.
|
||||||
*/
|
*/
|
||||||
waitForServiceWorkerTraffic(ms) {
|
waitForServiceWorkerTraffic(ms) {
|
||||||
if (firstWorkerRequest) return Promise.resolve(firstWorkerRequest);
|
if (firstWorkerRequest) return Promise.resolve(firstWorkerRequest);
|
||||||
@@ -686,8 +703,12 @@ async function installNetworkStubs(ctx, opts) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
installNetworkStubs,
|
installNetworkStubs,
|
||||||
|
DAPP_HTML,
|
||||||
DAPP_ORIGIN,
|
DAPP_ORIGIN,
|
||||||
DAPP_URL,
|
DAPP_URL,
|
||||||
|
PHISHING_DAPP_ORIGIN,
|
||||||
|
PHISHING_DAPP_URL,
|
||||||
|
WORKER_PROBE_URL,
|
||||||
FEE_ESTIMATE_WEI,
|
FEE_ESTIMATE_WEI,
|
||||||
FEE_RESERVE_WEI,
|
FEE_RESERVE_WEI,
|
||||||
STUB_COUNTERPARTY,
|
STUB_COUNTERPARTY,
|
||||||
|
|||||||
531
tests/e2e/run.js
531
tests/e2e/run.js
@@ -33,6 +33,7 @@ const {
|
|||||||
const {
|
const {
|
||||||
DAPP_ORIGIN,
|
DAPP_ORIGIN,
|
||||||
DAPP_URL,
|
DAPP_URL,
|
||||||
|
PHISHING_DAPP_URL,
|
||||||
FEE_ESTIMATE_WEI,
|
FEE_ESTIMATE_WEI,
|
||||||
FEE_RESERVE_WEI,
|
FEE_RESERVE_WEI,
|
||||||
STUB_COUNTERPARTY,
|
STUB_COUNTERPARTY,
|
||||||
@@ -42,6 +43,7 @@ const {
|
|||||||
STUB_TX_HASH,
|
STUB_TX_HASH,
|
||||||
} = require("./network");
|
} = require("./network");
|
||||||
const { DUST_THRESHOLD_MESSAGE } = require("../../src/popup/dustThreshold");
|
const { DUST_THRESHOLD_MESSAGE } = require("../../src/popup/dustThreshold");
|
||||||
|
const { NETWORKS } = require("../../src/shared/networks");
|
||||||
|
|
||||||
const TEST_TIMEOUT_MS = 120000;
|
const TEST_TIMEOUT_MS = 120000;
|
||||||
|
|
||||||
@@ -68,7 +70,11 @@ function withTimeout(promise, name) {
|
|||||||
const timeout = new Promise((_, reject) => {
|
const timeout = new Promise((_, reject) => {
|
||||||
timer = setTimeout(
|
timer = setTimeout(
|
||||||
() =>
|
() =>
|
||||||
reject(new Error("timed out after " + TEST_TIMEOUT_MS + "ms")),
|
reject(
|
||||||
|
new Error(
|
||||||
|
name + ": timed out after " + TEST_TIMEOUT_MS + "ms",
|
||||||
|
),
|
||||||
|
),
|
||||||
TEST_TIMEOUT_MS,
|
TEST_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -940,6 +946,328 @@ test("confirming removes the address and returns Home (#162)", async (env) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------- Settings screen (#229)
|
||||||
|
|
||||||
|
// Settings holds the densest run of $("...") lookups in the codebase, and
|
||||||
|
// until this section nothing drove it in a browser. One wrong id makes
|
||||||
|
// settings.init() throw, which aborts the rest of index.js init() before it
|
||||||
|
// renders anything at all — so a broken id does not degrade Settings, it
|
||||||
|
// leaves the whole popup blank. These tests assert the controls are there
|
||||||
|
// AND that they work, because "the view is visible" would still pass
|
||||||
|
// against a screen whose handlers were never wired.
|
||||||
|
|
||||||
|
// The four Token Spam Protection checkboxes, in markup order, with the
|
||||||
|
// src/shared/state.js key each one is bound to. All four default true.
|
||||||
|
const SPAM_FILTER_CHECKBOXES = [
|
||||||
|
{ id: "settings-hide-spoofed-symbols", key: "hideSpoofedSymbols" },
|
||||||
|
{ id: "settings-hide-low-holders", key: "hideLowHolderTokens" },
|
||||||
|
{ id: "settings-hide-fraud-contracts", key: "hideFraudContracts" },
|
||||||
|
{ id: "settings-hide-dust", key: "hideDustTransactions" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// The one toggled through a reopen. Chosen because nothing later in this
|
||||||
|
// suite depends on it: the other three filter token and transaction lists
|
||||||
|
// that the ConfirmTx and dApp sections go on to drive.
|
||||||
|
const TOGGLED_FILTER = "settings-hide-dust";
|
||||||
|
|
||||||
|
// Everything the Settings assertions below must observe, recorded as each
|
||||||
|
// group of them completes. The final test demands the exact set.
|
||||||
|
//
|
||||||
|
// The point is that a green run cannot mean the assertions were skipped.
|
||||||
|
// Navigation that silently fails already fails a test — visible() throws
|
||||||
|
// on a timeout — but an early return, a deleted test, or a body that
|
||||||
|
// stopped being reached would otherwise shrink this section quietly
|
||||||
|
// instead of reddening the run.
|
||||||
|
const SETTINGS_COVERAGE = [
|
||||||
|
"about-well",
|
||||||
|
"spam-checkbox-defaults",
|
||||||
|
"theme-select",
|
||||||
|
"network-select",
|
||||||
|
"selector-round-trip",
|
||||||
|
"selector-restore",
|
||||||
|
"toggle-off-survives-reopen",
|
||||||
|
"toggle-on-survives-reopen",
|
||||||
|
"wallet-list",
|
||||||
|
];
|
||||||
|
|
||||||
|
// A control read as the DOM has it, not as a selector claims: tag name and
|
||||||
|
// type distinguish a real <input type="checkbox"> from a <div> that merely
|
||||||
|
// carries the id, and `checked` is the live property rather than the
|
||||||
|
// attribute, so it reflects what init() assigned.
|
||||||
|
function controlState(page, id) {
|
||||||
|
return page.evaluate((elementId) => {
|
||||||
|
const el = document.getElementById(elementId);
|
||||||
|
if (!el) return null;
|
||||||
|
return {
|
||||||
|
tag: el.tagName.toLowerCase(),
|
||||||
|
type: el.type || "",
|
||||||
|
checked: el.checked,
|
||||||
|
value: el.value,
|
||||||
|
options: Array.from(el.options || []).map((o) => o.value),
|
||||||
|
};
|
||||||
|
}, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkboxStates(page) {
|
||||||
|
const out = {};
|
||||||
|
for (const { id } of SPAM_FILTER_CHECKBOXES) {
|
||||||
|
out[id] = await controlState(page, id);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSpamCheckbox(st, id, expected, where) {
|
||||||
|
assert(st !== null, "no element with id " + id + " on Settings " + where);
|
||||||
|
assert(
|
||||||
|
st.tag === "input" && st.type === "checkbox",
|
||||||
|
id + " is a <" + st.tag + " type=" + st.type + ">, not a checkbox",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
st.checked === expected,
|
||||||
|
id +
|
||||||
|
" reads " +
|
||||||
|
st.checked +
|
||||||
|
" " +
|
||||||
|
where +
|
||||||
|
", expected " +
|
||||||
|
expected +
|
||||||
|
" — the checkbox is on screen but not carrying the persisted value",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Settings renders with the whole screen populated (#229)", async (env) => {
|
||||||
|
await visible(env.page, "#view-main");
|
||||||
|
await openSettings(env.page);
|
||||||
|
|
||||||
|
// show() writes the About well near its end — only the debug well and
|
||||||
|
// the debug-mode checkbox follow it — and showView() is the last thing
|
||||||
|
// of all, so an id show() cannot find aborts before Settings is ever
|
||||||
|
// displayed. Reading these values back proves show() ran through to
|
||||||
|
// there, not just far enough to unhide the section. They are filled
|
||||||
|
// from build-time constants that always have a value, so empty means
|
||||||
|
// the write did not happen.
|
||||||
|
const about = await env.page.evaluate(() => {
|
||||||
|
const out = {};
|
||||||
|
for (const id of [
|
||||||
|
"about-license",
|
||||||
|
"about-author",
|
||||||
|
"about-version",
|
||||||
|
"about-release-date",
|
||||||
|
"about-commit-link",
|
||||||
|
]) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
out[id] = el === null ? null : el.textContent.trim();
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
for (const [id, text] of Object.entries(about)) {
|
||||||
|
assert(
|
||||||
|
text !== null && text.length > 0,
|
||||||
|
"the About well left #" +
|
||||||
|
id +
|
||||||
|
" unwritten: " +
|
||||||
|
JSON.stringify(about),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
env.settingsCoverage.add("about-well");
|
||||||
|
|
||||||
|
// The wallet list is rendered by settings.js rather than authored in
|
||||||
|
// index.html, so an empty container means renderWalletListSettings()
|
||||||
|
// did not run even though the screen came up.
|
||||||
|
const wallets = await env.page
|
||||||
|
.locator("#settings-wallet-list .settings-wallet-name")
|
||||||
|
.count();
|
||||||
|
assert(
|
||||||
|
wallets >= 2,
|
||||||
|
"Settings lists " +
|
||||||
|
wallets +
|
||||||
|
" wallets; the suite created two by this point",
|
||||||
|
);
|
||||||
|
env.settingsCoverage.add("wallet-list");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the four Token Spam Protection checkboxes render, defaulted on (#229)", async (env) => {
|
||||||
|
await openSettings(env.page);
|
||||||
|
const states = await checkboxStates(env.page);
|
||||||
|
for (const { id } of SPAM_FILTER_CHECKBOXES) {
|
||||||
|
assertSpamCheckbox(states[id], id, true, "on first render");
|
||||||
|
}
|
||||||
|
env.settingsCoverage.add("spam-checkbox-defaults");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the theme and network selectors render their real choices (#229)", async (env) => {
|
||||||
|
await openSettings(env.page);
|
||||||
|
|
||||||
|
const theme = await controlState(env.page, "settings-theme");
|
||||||
|
assert(theme !== null, "no #settings-theme element on Settings");
|
||||||
|
assert(
|
||||||
|
theme.tag === "select",
|
||||||
|
"#settings-theme is a <" + theme.tag + ">, not a <select>",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
theme.options.join(",") === "system,light,dark",
|
||||||
|
"the theme selector offers " + JSON.stringify(theme.options),
|
||||||
|
);
|
||||||
|
env.settingsCoverage.add("theme-select");
|
||||||
|
|
||||||
|
const network = await controlState(env.page, "settings-network");
|
||||||
|
assert(network !== null, "no #settings-network element on Settings");
|
||||||
|
assert(
|
||||||
|
network.tag === "select",
|
||||||
|
"#settings-network is a <" + network.tag + ">, not a <select>",
|
||||||
|
);
|
||||||
|
const wantNetworks = Object.keys(NETWORKS).sort().join(",");
|
||||||
|
assert(
|
||||||
|
network.options.slice().sort().join(",") === wantNetworks,
|
||||||
|
"the network selector offers " +
|
||||||
|
JSON.stringify(network.options) +
|
||||||
|
", expected the networks in src/shared/networks.js: " +
|
||||||
|
wantNetworks,
|
||||||
|
);
|
||||||
|
env.settingsCoverage.add("network-select");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reads both selectors in one page task, so a round trip cannot observe
|
||||||
|
// them at two different moments.
|
||||||
|
async function selectorValues(page) {
|
||||||
|
const theme = await controlState(page, "settings-theme");
|
||||||
|
const network = await controlState(page, "settings-network");
|
||||||
|
assert(theme !== null, "no #settings-theme element on Settings");
|
||||||
|
assert(network !== null, "no #settings-network element on Settings");
|
||||||
|
return { theme: theme.value, network: network.value };
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSelectors(got, wantTheme, wantNetwork, where) {
|
||||||
|
assert(
|
||||||
|
got.theme === wantTheme,
|
||||||
|
"the theme selector shows " +
|
||||||
|
JSON.stringify(got.theme) +
|
||||||
|
" " +
|
||||||
|
where +
|
||||||
|
", expected " +
|
||||||
|
JSON.stringify(wantTheme),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
got.network === wantNetwork,
|
||||||
|
"the network selector shows " +
|
||||||
|
JSON.stringify(got.network) +
|
||||||
|
" " +
|
||||||
|
where +
|
||||||
|
", expected " +
|
||||||
|
JSON.stringify(wantNetwork),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The two values the selectors are driven to. NEITHER is the first
|
||||||
|
// <option> of its <select> (`system` and `mainnet` are), and that is the
|
||||||
|
// entire point: the first option is what the DOM reports with no
|
||||||
|
// JavaScript involved at all, so asserting it would pass just as happily
|
||||||
|
// against a Settings screen that never assigned anything. Only a value
|
||||||
|
// that went out through the change handler and saveState(), and came back
|
||||||
|
// through loadState() and the assignment show()/init() makes, can be read
|
||||||
|
// here.
|
||||||
|
const NONDEFAULT_THEME = "dark";
|
||||||
|
const NONDEFAULT_NETWORK = "sepolia";
|
||||||
|
|
||||||
|
test("the theme and network selectors carry a non-default persisted value (#229)", async (env) => {
|
||||||
|
await openSettings(env.page);
|
||||||
|
|
||||||
|
// selectOption() fires "change", which is what the handlers bind.
|
||||||
|
await env.page.selectOption("#settings-theme", NONDEFAULT_THEME);
|
||||||
|
await env.page.selectOption("#settings-network", NONDEFAULT_NETWORK);
|
||||||
|
|
||||||
|
await reopenPopup(env, "#view-settings");
|
||||||
|
|
||||||
|
assertSelectors(
|
||||||
|
await selectorValues(env.page),
|
||||||
|
NONDEFAULT_THEME,
|
||||||
|
NONDEFAULT_NETWORK,
|
||||||
|
"after reopening the popup",
|
||||||
|
);
|
||||||
|
env.settingsCoverage.add("selector-round-trip");
|
||||||
|
|
||||||
|
// Restore, the same way round, and assert the restore actually took
|
||||||
|
// rather than trusting it: the later sections inherit this fixture,
|
||||||
|
// and a selector stuck on `dark`/`sepolia` would otherwise be
|
||||||
|
// indistinguishable here from one that persists correctly. Switching
|
||||||
|
// the network back also returns state.rpcUrl and state.blockscoutUrl
|
||||||
|
// to the mainnet defaults that onChainSwitch() overwrote, which are
|
||||||
|
// the values src/shared/state.js starts with.
|
||||||
|
await env.page.selectOption("#settings-theme", "system");
|
||||||
|
await env.page.selectOption("#settings-network", "mainnet");
|
||||||
|
|
||||||
|
await reopenPopup(env, "#view-settings");
|
||||||
|
|
||||||
|
assertSelectors(
|
||||||
|
await selectorValues(env.page),
|
||||||
|
"system",
|
||||||
|
"mainnet",
|
||||||
|
"after restoring and reopening the popup",
|
||||||
|
);
|
||||||
|
env.settingsCoverage.add("selector-restore");
|
||||||
|
});
|
||||||
|
|
||||||
|
// The functional half. A checkbox that renders but is not wired looks
|
||||||
|
// identical on screen; only a value that survives being written to storage
|
||||||
|
// and read back by a fresh page load tells the two apart. That round trip
|
||||||
|
// runs through the change handler, saveState(), loadState() and the
|
||||||
|
// assignment init() makes — every part of the wiring at once.
|
||||||
|
test("a spam filter toggled in Settings survives a popup reopen (#229)", async (env) => {
|
||||||
|
await openSettings(env.page);
|
||||||
|
await env.page.click("#" + TOGGLED_FILTER);
|
||||||
|
|
||||||
|
const immediately = await controlState(env.page, TOGGLED_FILTER);
|
||||||
|
assert(
|
||||||
|
immediately.checked === false,
|
||||||
|
"clicking #" + TOGGLED_FILTER + " did not clear it",
|
||||||
|
);
|
||||||
|
|
||||||
|
await reopenPopup(env, "#view-settings");
|
||||||
|
|
||||||
|
const after = await checkboxStates(env.page);
|
||||||
|
for (const { id } of SPAM_FILTER_CHECKBOXES) {
|
||||||
|
assertSpamCheckbox(
|
||||||
|
after[id],
|
||||||
|
id,
|
||||||
|
id !== TOGGLED_FILTER,
|
||||||
|
"after reopening the popup",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
env.settingsCoverage.add("toggle-off-survives-reopen");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("turning the same filter back on survives a reopen too (#229)", async (env) => {
|
||||||
|
await openSettings(env.page);
|
||||||
|
await env.page.click("#" + TOGGLED_FILTER);
|
||||||
|
|
||||||
|
await reopenPopup(env, "#view-settings");
|
||||||
|
|
||||||
|
// Restores the fixture the later sections inherit, and rules out a
|
||||||
|
// checkbox that persists "off" only because it is stuck there.
|
||||||
|
const after = await checkboxStates(env.page);
|
||||||
|
for (const { id } of SPAM_FILTER_CHECKBOXES) {
|
||||||
|
assertSpamCheckbox(after[id], id, true, "after toggling back on");
|
||||||
|
}
|
||||||
|
env.settingsCoverage.add("toggle-on-survives-reopen");
|
||||||
|
|
||||||
|
await env.page.click("#btn-settings-back");
|
||||||
|
await visible(env.page, "#view-main");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the Settings assertions above all ran (#229)", async (env) => {
|
||||||
|
const seen = [...env.settingsCoverage].sort();
|
||||||
|
const want = SETTINGS_COVERAGE.slice().sort();
|
||||||
|
assert(
|
||||||
|
seen.join(",") === want.join(","),
|
||||||
|
"the Settings section covered " +
|
||||||
|
JSON.stringify(seen) +
|
||||||
|
" but must cover " +
|
||||||
|
JSON.stringify(want) +
|
||||||
|
" — a green run here would otherwise mean only that fewer " +
|
||||||
|
"assertions ran, not that they passed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// ------------------------------------------------ dust threshold (#233)
|
// ------------------------------------------------ dust threshold (#233)
|
||||||
|
|
||||||
// The popup size README documents the UI as designed for. Pages in this
|
// The popup size README documents the UI as designed for. Pages in this
|
||||||
@@ -1742,9 +2070,9 @@ async function extensionActiveAddress(page) {
|
|||||||
return getAddress(address);
|
return getAddress(address);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openDapp(ctx) {
|
async function openDapp(ctx, url = DAPP_URL) {
|
||||||
const page = await ctx.newPage();
|
const page = await ctx.newPage();
|
||||||
await page.goto(DAPP_URL);
|
await page.goto(url);
|
||||||
// window.ethereum is not the fixture's doing — it is the shipped
|
// window.ethereum is not the fixture's doing — it is the shipped
|
||||||
// MAIN-world content script. Waiting for it is waiting for the real
|
// MAIN-world content script. Waiting for it is waiting for the real
|
||||||
// provider to have injected itself into a real http(s) origin.
|
// provider to have injected itself into a real http(s) origin.
|
||||||
@@ -1856,32 +2184,13 @@ async function reserveApprovalTab(env) {
|
|||||||
// one down with it.
|
// one down with it.
|
||||||
env.approvalTab = await env.ctx.newPage();
|
env.approvalTab = await env.ctx.newPage();
|
||||||
|
|
||||||
// The one accommodation this section makes to the shipped code, and the
|
// This tab runs the shipped popup with nothing patched. The site
|
||||||
// reason for it.
|
// approval buttons decide and then close on the next line, and the two
|
||||||
//
|
// site-approval tests below are therefore the real-browser
|
||||||
// Both approval buttons call runtime.sendMessage() and then window.close()
|
// approve-then-immediate-close and reject-then-immediate-close cases: the
|
||||||
// on the next line. Closing this page disconnects the approval port, and
|
// decision rides the approval port, which also carries the disconnect the
|
||||||
// the disconnect handler in src/background/index.js settles a pending
|
// close causes, so it is delivered ahead of it and the outcome does not
|
||||||
// site approval as a rejection. In a tab those two race and the teardown
|
// depend on the teardown timing (#275).
|
||||||
// wins: the approve message is never acted on, and the page is told the
|
|
||||||
// user rejected. Measured — with the close left in place the approval
|
|
||||||
// resolves as a rejection every time; with it deferred it resolves as an
|
|
||||||
// approval every time.
|
|
||||||
//
|
|
||||||
// It is deferred, not removed: the harness closes the page itself once
|
|
||||||
// the outcome has been observed, which is what window.close() would have
|
|
||||||
// done, only after the message it was racing has been processed.
|
|
||||||
//
|
|
||||||
// This affects the site-connection prompt only. The sign and transaction
|
|
||||||
// prompts run in windows the extension opens itself, with window.close()
|
|
||||||
// untouched, and their disconnect handler deliberately keeps a tx or sign
|
|
||||||
// approval pending rather than rejecting it — so there is no race there
|
|
||||||
// to accommodate. Whether the same ordering holds in a real toolbar popup
|
|
||||||
// is not observable from a headless harness and is reported rather than
|
|
||||||
// assumed either way.
|
|
||||||
await env.approvalTab.addInitScript(() => {
|
|
||||||
window.close = function () {};
|
|
||||||
});
|
|
||||||
await env.approvalTab.goto("about:blank");
|
await env.approvalTab.goto("about:blank");
|
||||||
await sleep(APPROVAL_TAB_SETTLE_MS);
|
await sleep(APPROVAL_TAB_SETTLE_MS);
|
||||||
return env.approvalTab;
|
return env.approvalTab;
|
||||||
@@ -1930,6 +2239,95 @@ async function closeApprovalPages(ctx) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Click a button whose own handler closes the window it lives in — every
|
||||||
|
// Reject, and Allow on the site prompt.
|
||||||
|
//
|
||||||
|
// page.click() dispatches the click and then waits for the renderer to
|
||||||
|
// acknowledge it, and a page torn down by the handler never gets to. The
|
||||||
|
// dispatch is what the test needs and the log shows it happening ("performing
|
||||||
|
// click action") immediately before the failure; the page going away is the
|
||||||
|
// button working, not the click failing. Observed on #btn-reject-sign and
|
||||||
|
// #btn-reject-tx, whose windows have always closed themselves.
|
||||||
|
//
|
||||||
|
// What the swallow costs is not the same for every button, so neither is what
|
||||||
|
// proves the click landed:
|
||||||
|
//
|
||||||
|
// #btn-reject-sign, #btn-reject-tx — their disconnect leaves the approval
|
||||||
|
// pending, so a click that never landed leaves the dApp promise unsettled
|
||||||
|
// and the assertion after the call fails on its own.
|
||||||
|
// #btn-approve — only a decision resolves the promise, and a swallowed click
|
||||||
|
// cannot produce settled === "resolved".
|
||||||
|
// #btn-reject on the site prompt — NOT self-proving. A page that went away
|
||||||
|
// without the click landing disconnects the approval port, the background
|
||||||
|
// settles that as 4001, and 4001 is exactly what assertUserRejection
|
||||||
|
// accepts. That call site arms the click trace below and asserts it.
|
||||||
|
//
|
||||||
|
// A button that is missing or unclickable raises a different error, which is
|
||||||
|
// rethrown.
|
||||||
|
async function clickAndClose(page, selector) {
|
||||||
|
try {
|
||||||
|
await page.click(selector);
|
||||||
|
} catch (e) {
|
||||||
|
if (!String((e && e.message) || e).includes("has been closed")) throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evidence that a click reached the button, for the button whose outcome
|
||||||
|
// cannot tell.
|
||||||
|
//
|
||||||
|
// A capture-phase listener on the document runs ahead of the button's own
|
||||||
|
// handler and writes one key with localStorage.setItem(), which is synchronous
|
||||||
|
// and therefore already in the browser process when the handler tears the page
|
||||||
|
// down a line later. Any other page of the extension origin can read it back,
|
||||||
|
// and env.page is one. The listener only observes: nothing about the shipped
|
||||||
|
// decide-then-close is deferred, patched or reordered.
|
||||||
|
const CLICK_TRACE_KEY = "autistmask-e2e-click-landed";
|
||||||
|
|
||||||
|
async function armClickTrace(env, page, selector) {
|
||||||
|
await env.page.evaluate(
|
||||||
|
(key) => localStorage.removeItem(key),
|
||||||
|
CLICK_TRACE_KEY,
|
||||||
|
);
|
||||||
|
await page.evaluate(
|
||||||
|
({ key, sel }) => {
|
||||||
|
document.addEventListener(
|
||||||
|
"click",
|
||||||
|
(e) => {
|
||||||
|
const target = e.target;
|
||||||
|
if (target && target.closest && target.closest(sel)) {
|
||||||
|
localStorage.setItem(key, sel);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ key: CLICK_TRACE_KEY, sel: selector },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The write crosses processes to reach env.page's renderer, so it is waited
|
||||||
|
// for rather than read once. Nothing else in the test is timed on this.
|
||||||
|
async function assertClickLanded(env, selector, timeout = 5000) {
|
||||||
|
const deadline = Date.now() + timeout;
|
||||||
|
let seen;
|
||||||
|
for (;;) {
|
||||||
|
seen = await env.page.evaluate(
|
||||||
|
(key) => localStorage.getItem(key),
|
||||||
|
CLICK_TRACE_KEY,
|
||||||
|
);
|
||||||
|
if (seen === selector || Date.now() > deadline) break;
|
||||||
|
await sleep(25);
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
seen === selector,
|
||||||
|
"the click on " +
|
||||||
|
selector +
|
||||||
|
" never reached the button, so the outcome below proves nothing " +
|
||||||
|
"about it: trace was " +
|
||||||
|
JSON.stringify(seen),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Record every message the approval window sends to the background worker.
|
// Record every message the approval window sends to the background worker.
|
||||||
//
|
//
|
||||||
// This is the direct observation the password check needs. It is installed
|
// This is the direct observation the password check needs. It is installed
|
||||||
@@ -2146,11 +2544,24 @@ test("eth_requestAccounts rejected at the prompt returns a rejection (#183)", as
|
|||||||
JSON.stringify(hostname),
|
JSON.stringify(hostname),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The control for the phishing test below: this origin is not on the
|
||||||
|
// blocklist, so the banner must be absent here. Without it a banner
|
||||||
|
// that was simply always visible would satisfy that test.
|
||||||
|
assert(
|
||||||
|
await popup.locator("#approve-site-phishing-warning").isHidden(),
|
||||||
|
"the phishing warning is showing for an origin that is not on " +
|
||||||
|
"the blocklist, so its appearance proves nothing",
|
||||||
|
);
|
||||||
|
|
||||||
// Deliberately not remembered: a remembered rejection lands the
|
// Deliberately not remembered: a remembered rejection lands the
|
||||||
// origin in deniedSites and every later test in this section is
|
// origin in deniedSites and every later test in this section is
|
||||||
// auto-rejected with no prompt at all, which would look like a pass.
|
// auto-rejected with no prompt at all, which would look like a pass.
|
||||||
await popup.uncheck("#approve-remember");
|
await popup.uncheck("#approve-remember");
|
||||||
await popup.click("#btn-reject");
|
// The rejection this asserts is also what an unclicked prompt that
|
||||||
|
// simply went away produces, so the click itself is witnessed.
|
||||||
|
await armClickTrace(env, popup, "#btn-reject");
|
||||||
|
await clickAndClose(popup, "#btn-reject");
|
||||||
|
await assertClickLanded(env, "#btn-reject");
|
||||||
|
|
||||||
await assertUserRejection(
|
await assertUserRejection(
|
||||||
env.dapp,
|
env.dapp,
|
||||||
@@ -2181,7 +2592,7 @@ test("eth_requestAccounts approved returns the selected address (#183)", async (
|
|||||||
// does not, and the sign and transaction tests below all require the
|
// does not, and the sign and transaction tests below all require the
|
||||||
// origin to still be authorized.
|
// origin to still be authorized.
|
||||||
await popup.check("#approve-remember");
|
await popup.check("#approve-remember");
|
||||||
await popup.click("#btn-approve");
|
await clickAndClose(popup, "#btn-approve");
|
||||||
|
|
||||||
outcome = await settleRequest(env.dapp, "accounts");
|
outcome = await settleRequest(env.dapp, "accounts");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2205,6 +2616,53 @@ test("eth_requestAccounts approved returns the selected address (#183)", async (
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a connect request from a blocklisted site is flagged (#219)", async (env) => {
|
||||||
|
// The vendored blocklist, end to end: a real entry from the shipped
|
||||||
|
// artifact, served as a real http(s) origin, reaching the real background
|
||||||
|
// check and the real approval screen. Nothing about the list is stubbed —
|
||||||
|
// there is nothing left to stub, since the extension no longer fetches it.
|
||||||
|
const phishingDapp = await openDapp(env.ctx, PHISHING_DAPP_URL);
|
||||||
|
const hostname = new URL(PHISHING_DAPP_URL).hostname;
|
||||||
|
try {
|
||||||
|
await reserveApprovalTab(env);
|
||||||
|
await startRequest(
|
||||||
|
phishingDapp,
|
||||||
|
"phishing-accounts",
|
||||||
|
"eth_requestAccounts",
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const popup = await openSiteApprovalPopup(env);
|
||||||
|
try {
|
||||||
|
await visible(popup, "#view-approve-site");
|
||||||
|
|
||||||
|
const shown = await popup.locator("#approve-hostname").innerText();
|
||||||
|
assert(
|
||||||
|
shown === hostname,
|
||||||
|
"the site prompt names the wrong origin: " +
|
||||||
|
JSON.stringify(shown),
|
||||||
|
);
|
||||||
|
|
||||||
|
await visible(popup, "#approve-site-phishing-warning");
|
||||||
|
console.log("# phishing warning shown for " + hostname);
|
||||||
|
|
||||||
|
// Not remembered: a remembered decision for this origin would
|
||||||
|
// outlive the test.
|
||||||
|
await popup.uncheck("#approve-remember");
|
||||||
|
await popup.click("#btn-reject");
|
||||||
|
|
||||||
|
await assertUserRejection(
|
||||||
|
phishingDapp,
|
||||||
|
"phishing-accounts",
|
||||||
|
"the blocklisted site's eth_requestAccounts",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await closeApprovalPages(env.ctx);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await phishingDapp.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("personal_sign signs, and the signature recovers to the address (#183)", async (env) => {
|
test("personal_sign signs, and the signature recovers to the address (#183)", async (env) => {
|
||||||
await startRequest(env.dapp, "sign", "personal_sign", [
|
await startRequest(env.dapp, "sign", "personal_sign", [
|
||||||
SIGN_HEX,
|
SIGN_HEX,
|
||||||
@@ -2289,7 +2747,7 @@ test("personal_sign rejected returns a rejection to the page (#183)", async (env
|
|||||||
]);
|
]);
|
||||||
const popup = await waitForApprovalWindow(env.ctx);
|
const popup = await waitForApprovalWindow(env.ctx);
|
||||||
await visible(popup, "#view-approve-sign");
|
await visible(popup, "#view-approve-sign");
|
||||||
await popup.click("#btn-reject-sign");
|
await clickAndClose(popup, "#btn-reject-sign");
|
||||||
|
|
||||||
await assertUserRejection(
|
await assertUserRejection(
|
||||||
env.dapp,
|
env.dapp,
|
||||||
@@ -2392,7 +2850,7 @@ test("eth_signTypedData_v4 rejected returns a rejection to the page (#183)", asy
|
|||||||
]);
|
]);
|
||||||
const popup = await waitForApprovalWindow(env.ctx);
|
const popup = await waitForApprovalWindow(env.ctx);
|
||||||
await visible(popup, "#view-approve-sign");
|
await visible(popup, "#view-approve-sign");
|
||||||
await popup.click("#btn-reject-sign");
|
await clickAndClose(popup, "#btn-reject-sign");
|
||||||
|
|
||||||
await assertUserRejection(
|
await assertUserRejection(
|
||||||
env.dapp,
|
env.dapp,
|
||||||
@@ -2550,7 +3008,7 @@ test("eth_sendTransaction rejected broadcasts nothing (#183)", async (env) => {
|
|||||||
]);
|
]);
|
||||||
const popup = await waitForApprovalWindow(env.ctx);
|
const popup = await waitForApprovalWindow(env.ctx);
|
||||||
await visible(popup, "#view-approve-tx");
|
await visible(popup, "#view-approve-tx");
|
||||||
await popup.click("#btn-reject-tx");
|
await clickAndClose(popup, "#btn-reject-tx");
|
||||||
|
|
||||||
await assertUserRejection(
|
await assertUserRejection(
|
||||||
env.dapp,
|
env.dapp,
|
||||||
@@ -2668,6 +3126,11 @@ async function main() {
|
|||||||
// The recovery phrase of the wallet created in test 2, so later
|
// The recovery phrase of the wallet created in test 2, so later
|
||||||
// tests can assert on the real secret rather than its shape.
|
// tests can assert on the real secret rather than its shape.
|
||||||
phrase: null,
|
phrase: null,
|
||||||
|
// What the Settings section (#229) actually observed. A guard test
|
||||||
|
// at the end of that section demands the full set, so a skipped or
|
||||||
|
// silently shortened assertion reddens the run instead of shrinking
|
||||||
|
// it.
|
||||||
|
settingsCoverage: new Set(),
|
||||||
// Confirmation-screen heights, measured in the pending state and
|
// Confirmation-screen heights, measured in the pending state and
|
||||||
// compared against every later state of the same screen.
|
// compared against every later state of the same screen.
|
||||||
ethPendingHeight: null,
|
ethPendingHeight: null,
|
||||||
|
|||||||
@@ -1,93 +1,128 @@
|
|||||||
// Extension storage stub for the Node test environment. The module resolves
|
// The phishing blocklist is vendored at build time and shipped as digests:
|
||||||
// the storage API on use, so this only has to exist before the first call.
|
// script/vendor-blocklist writes src/shared/phishingBlocklist.json, and nothing
|
||||||
// Values round-trip through JSON the way structured cloning would, so a test
|
// fetches anything at runtime. Two things therefore have to be proven here, and
|
||||||
// cannot pass by holding a live reference to the module's own array.
|
// the second is the one that would otherwise fail silently:
|
||||||
const storageStore = {};
|
//
|
||||||
global.chrome = {
|
// - real domains from the vendored list are detected, and clean ones are not.
|
||||||
storage: {
|
// - a malformed artifact fails loudly. Every way of getting the artifact
|
||||||
local: {
|
// wrong produces a blocklist that matches nothing while looking healthy,
|
||||||
get: async (key) =>
|
// which is a phishing check that answers "no" to everything.
|
||||||
Object.prototype.hasOwnProperty.call(storageStore, key)
|
|
||||||
? { [key]: JSON.parse(JSON.stringify(storageStore[key])) }
|
|
||||||
: {},
|
|
||||||
set: async (items) => {
|
|
||||||
for (const [key, value] of Object.entries(items)) {
|
|
||||||
storageStore[key] = JSON.parse(JSON.stringify(value));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
remove: async (key) => {
|
|
||||||
delete storageStore[key];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isPhishingDomain,
|
isPhishingDomain,
|
||||||
loadConfig,
|
|
||||||
getBlocklistSize,
|
getBlocklistSize,
|
||||||
getDeltaSize,
|
|
||||||
hostnameVariants,
|
hostnameVariants,
|
||||||
DELTA_STORAGE_KEY,
|
|
||||||
_reset,
|
|
||||||
_getVendoredBlacklistSize,
|
|
||||||
_getDeltaBlacklist,
|
|
||||||
} = require("../src/shared/phishingDomains");
|
} = require("../src/shared/phishingDomains");
|
||||||
|
const { HASH_HEX_CHARS, hashDomain } = require("../src/shared/domainHash");
|
||||||
|
const vendored = require("../src/shared/phishingBlocklist.json");
|
||||||
|
|
||||||
function clearStorage() {
|
// Domains present in the vendored list at the pinned upstream commit. Upstream
|
||||||
for (const key of Object.keys(storageStore)) {
|
// prunes as well as adds, so re-vendoring can retire one of these and turn this
|
||||||
delete storageStore[key];
|
// red; that is the intended prompt to pick a current entry, not a licence to
|
||||||
}
|
// weaken the assertion into "some domain somewhere matches".
|
||||||
}
|
const LISTED = [
|
||||||
|
"0-google.ph",
|
||||||
|
"myetheywallet.com",
|
||||||
|
// An underscore is not legal in a hostname, but DNS carries one and
|
||||||
|
// browsers resolve it, and upstream lists well over a hundred phishing
|
||||||
|
// sites that use one. The vendoring transform keeps them.
|
||||||
|
"phntum-wallett.godaddysites.com",
|
||||||
|
"coinbase_prologin1.godaddysites.com",
|
||||||
|
];
|
||||||
|
|
||||||
// The MV3 service worker is torn down when idle and re-evaluated on the next
|
// Not on the list, and the kind of host a user actually visits.
|
||||||
// event, which wipes every module-level variable. Re-requiring the module with
|
const CLEAN = ["etherscan.io", "example.com", "opensea.io", "sneak.berlin"];
|
||||||
// the registry reset is exactly that: fresh in-memory state, same extension
|
|
||||||
// storage underneath.
|
|
||||||
function restartWorker() {
|
|
||||||
jest.resetModules();
|
|
||||||
return require("../src/shared/phishingDomains");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset delta state before each test to avoid cross-test contamination.
|
|
||||||
// Note: vendored sets are immutable and always present.
|
|
||||||
beforeEach(() => {
|
|
||||||
_reset();
|
|
||||||
clearStorage();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("phishingDomains", () => {
|
|
||||||
describe("vendored blocklist", () => {
|
describe("vendored blocklist", () => {
|
||||||
test("vendored blacklist is loaded from bundled JSON", () => {
|
test("the artifact holds the whole list", () => {
|
||||||
// The vendored blocklist should have a large number of entries
|
|
||||||
expect(_getVendoredBlacklistSize()).toBeGreaterThan(100000);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("detects domains from vendored blacklist", () => {
|
|
||||||
// These are well-known phishing domains in the vendored list
|
|
||||||
expect(isPhishingDomain("hopprotocol.pro")).toBe(true);
|
|
||||||
expect(isPhishingDomain("blast-pools.pages.dev")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("getBlocklistSize includes vendored entries", () => {
|
|
||||||
expect(getBlocklistSize()).toBeGreaterThan(100000);
|
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", () => {
|
describe("hostnameVariants", () => {
|
||||||
test("returns exact hostname plus parent domains", () => {
|
test("returns exact hostname plus parent domains", () => {
|
||||||
const variants = hostnameVariants("sub.evil.com");
|
expect(hostnameVariants("sub.evil.com")).toEqual([
|
||||||
expect(variants).toEqual(["sub.evil.com", "evil.com"]);
|
"sub.evil.com",
|
||||||
|
"evil.com",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns just the hostname for a bare domain", () => {
|
test("returns just the hostname for a bare domain", () => {
|
||||||
const variants = hostnameVariants("example.com");
|
expect(hostnameVariants("example.com")).toEqual(["example.com"]);
|
||||||
expect(variants).toEqual(["example.com"]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("handles deep subdomain chains", () => {
|
test("handles deep subdomain chains", () => {
|
||||||
const variants = hostnameVariants("a.b.c.d.com");
|
expect(hostnameVariants("a.b.c.d.com")).toEqual([
|
||||||
expect(variants).toEqual([
|
|
||||||
"a.b.c.d.com",
|
"a.b.c.d.com",
|
||||||
"b.c.d.com",
|
"b.c.d.com",
|
||||||
"c.d.com",
|
"c.d.com",
|
||||||
@@ -96,478 +131,89 @@ describe("phishingDomains", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("lowercases hostnames", () => {
|
test("lowercases hostnames", () => {
|
||||||
const variants = hostnameVariants("Evil.COM");
|
expect(hostnameVariants("Evil.COM")).toEqual(["evil.com"]);
|
||||||
expect(variants).toEqual(["evil.com"]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("delta computation via loadConfig", () => {
|
describe("domain hashing", () => {
|
||||||
test("loadConfig computes delta of new entries not in vendored list", () => {
|
test("a digest is the declared width of lowercase hex", () => {
|
||||||
loadConfig({
|
const hash = hashDomain("example.com");
|
||||||
blacklist: [
|
expect(hash).toHaveLength(HASH_HEX_CHARS);
|
||||||
"brand-new-scam-site-xyz123.com",
|
expect(hash).toMatch(/^[0-9a-f]+$/);
|
||||||
"hopprotocol.pro", // already in vendored
|
|
||||||
],
|
|
||||||
});
|
|
||||||
// Only the new domain should be in the delta
|
|
||||||
expect(
|
|
||||||
_getDeltaBlacklist().has("brand-new-scam-site-xyz123.com"),
|
|
||||||
).toBe(true);
|
|
||||||
expect(_getDeltaBlacklist().has("hopprotocol.pro")).toBe(false);
|
|
||||||
expect(getDeltaSize()).toBe(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("re-loading config replaces previous delta", () => {
|
test("hashing is case-insensitive, so lookups are too", () => {
|
||||||
loadConfig({
|
expect(hashDomain("Evil.COM")).toBe(hashDomain("evil.com"));
|
||||||
blacklist: ["first-scam-xyz.com"],
|
|
||||||
});
|
|
||||||
expect(isPhishingDomain("first-scam-xyz.com")).toBe(true);
|
|
||||||
|
|
||||||
loadConfig({
|
|
||||||
blacklist: ["second-scam-xyz.com"],
|
|
||||||
});
|
|
||||||
expect(isPhishingDomain("first-scam-xyz.com")).toBe(false);
|
|
||||||
expect(isPhishingDomain("second-scam-xyz.com")).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("getBlocklistSize includes both vendored and delta", () => {
|
test("different domains get different digests", () => {
|
||||||
const baseSize = getBlocklistSize();
|
expect(hashDomain("evil.com")).not.toBe(hashDomain("evil.org"));
|
||||||
loadConfig({
|
|
||||||
blacklist: ["delta-only-scam-xyz.com"],
|
|
||||||
});
|
|
||||||
expect(getBlocklistSize()).toBe(baseSize + 1);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("isPhishingDomain with delta + vendored", () => {
|
// A blocklist that silently matches nothing is the failure this module must not
|
||||||
test("detects domain from delta blacklist", () => {
|
// have, so each way of breaking the artifact is required to throw at load. The
|
||||||
loadConfig({
|
// generator is the only thing that writes this file, but "the generator is
|
||||||
blacklist: ["fresh-scam-xyz.com"],
|
// 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.
|
||||||
expect(isPhishingDomain("fresh-scam-xyz.com")).toBe(true);
|
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", () => {
|
function loadWith(artifact) {
|
||||||
// No delta loaded — vendored still works
|
let mod;
|
||||||
expect(isPhishingDomain("hopprotocol.pro")).toBe(true);
|
jest.isolateModules(() => {
|
||||||
|
jest.doMock(
|
||||||
|
"../src/shared/phishingBlocklist.json",
|
||||||
|
() => artifact,
|
||||||
|
{
|
||||||
|
virtual: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
mod = require("../src/shared/phishingDomains");
|
||||||
});
|
});
|
||||||
|
return mod;
|
||||||
test("returns false for clean domains", () => {
|
|
||||||
expect(isPhishingDomain("etherscan.io")).toBe(false);
|
|
||||||
expect(isPhishingDomain("example.com")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("detects subdomain of blacklisted domain (vendored)", () => {
|
|
||||||
expect(isPhishingDomain("app.hopprotocol.pro")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("detects subdomain of blacklisted domain (delta)", () => {
|
|
||||||
loadConfig({
|
|
||||||
blacklist: ["delta-phish-xyz.com"],
|
|
||||||
});
|
|
||||||
expect(isPhishingDomain("sub.delta-phish-xyz.com")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("case-insensitive matching", () => {
|
|
||||||
loadConfig({
|
|
||||||
blacklist: ["Delta-Scam-XYZ.COM"],
|
|
||||||
});
|
|
||||||
expect(isPhishingDomain("delta-scam-xyz.com")).toBe(true);
|
|
||||||
expect(isPhishingDomain("DELTA-SCAM-XYZ.COM")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns false for empty/null hostname", () => {
|
|
||||||
expect(isPhishingDomain("")).toBe(false);
|
|
||||||
expect(isPhishingDomain(null)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles config with no blacklist key", () => {
|
|
||||||
loadConfig({});
|
|
||||||
expect(getDeltaSize()).toBe(0);
|
|
||||||
// Vendored list still works
|
|
||||||
expect(isPhishingDomain("hopprotocol.pro")).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("extension storage persistence", () => {
|
|
||||||
test("delta is persisted to extension storage, not localStorage", async () => {
|
|
||||||
await loadConfig({
|
|
||||||
blacklist: ["persisted-scam-xyz.com"],
|
|
||||||
});
|
|
||||||
const stored = storageStore[DELTA_STORAGE_KEY];
|
|
||||||
expect(stored).toBeDefined();
|
|
||||||
expect(stored.blacklist).toContain("persisted-scam-xyz.com");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the fetch timestamp is persisted alongside the delta", async () => {
|
|
||||||
const before = Date.now();
|
|
||||||
await loadConfig({ blacklist: ["timestamped-scam-xyz.com"] });
|
|
||||||
const stored = storageStore[DELTA_STORAGE_KEY];
|
|
||||||
expect(typeof stored.lastFetchTime).toBe("number");
|
|
||||||
expect(stored.lastFetchTime).toBeGreaterThanOrEqual(before);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an oversized delta is dropped entirely, timestamp included", async () => {
|
|
||||||
// A record above the 256 KiB cap is not worth keeping; the
|
|
||||||
// timestamp goes with it so the next start re-fetches rather than
|
|
||||||
// claiming freshness for a delta that was never stored.
|
|
||||||
const huge = [];
|
|
||||||
for (let i = 0; i < 20000; i++) {
|
|
||||||
huge.push(`oversize-scam-${i}-xyzxyzxyzxyzxyz.com`);
|
|
||||||
}
|
}
|
||||||
await loadConfig({ blacklist: huge });
|
|
||||||
expect(storageStore[DELTA_STORAGE_KEY]).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("delta is cleared on _reset", () => {
|
|
||||||
loadConfig({
|
|
||||||
blacklist: ["temp-scam-xyz.com"],
|
|
||||||
});
|
|
||||||
expect(getDeltaSize()).toBe(1);
|
|
||||||
_reset();
|
|
||||||
expect(getDeltaSize()).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("real-world blocklist patterns", () => {
|
|
||||||
test("detects known phishing domains from vendored list", () => {
|
|
||||||
expect(isPhishingDomain("uniswap-trade.web.app")).toBe(true);
|
|
||||||
expect(isPhishingDomain("hopprotocol.pro")).toBe(true);
|
|
||||||
expect(isPhishingDomain("blast-pools.pages.dev")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not flag legitimate domains", () => {
|
|
||||||
expect(isPhishingDomain("opensea.io")).toBe(false);
|
|
||||||
expect(isPhishingDomain("etherscan.io")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("phishing list across a service worker restart", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
clearStorage();
|
|
||||||
jest.resetModules();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
delete global.fetch;
|
jest.dontMock("../src/shared/phishingBlocklist.json");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a revived worker restores the persisted delta without re-fetching", async () => {
|
test("the control artifact loads", () => {
|
||||||
const first = require("../src/shared/phishingDomains");
|
expect(loadWith(GOOD).getBlocklistSize()).toBe(2);
|
||||||
await first.loadConfig({ blacklist: ["restart-scam-xyz.com"] });
|
|
||||||
|
|
||||||
const revived = restartWorker();
|
|
||||||
// Nothing in memory yet — this is a brand new module instance.
|
|
||||||
expect(revived.getDeltaSize()).toBe(0);
|
|
||||||
|
|
||||||
global.fetch = jest.fn();
|
|
||||||
await revived.initPhishingList();
|
|
||||||
|
|
||||||
expect(global.fetch).not.toHaveBeenCalled();
|
|
||||||
expect(revived.getDeltaSize()).toBe(1);
|
|
||||||
expect(revived.isPhishingDomain("restart-scam-xyz.com")).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("repeated wakes inside the cache window never re-fetch", async () => {
|
test("a different digest algorithm throws", () => {
|
||||||
const first = require("../src/shared/phishingDomains");
|
expect(() => loadWith({ ...GOOD, algorithm: "md5" })).toThrow(
|
||||||
await first.loadConfig({ blacklist: ["no-storm-scam-xyz.com"] });
|
/algorithm/,
|
||||||
|
|
||||||
global.fetch = jest.fn();
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
const revived = restartWorker();
|
|
||||||
await revived.initPhishingList();
|
|
||||||
}
|
|
||||||
expect(global.fetch).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a persisted timestamp older than the TTL causes a fetch on startup", async () => {
|
|
||||||
const first = require("../src/shared/phishingDomains");
|
|
||||||
await first.loadConfig({ blacklist: ["stale-scam-xyz.com"] });
|
|
||||||
|
|
||||||
// Age the persisted record past the 24-hour TTL.
|
|
||||||
storageStore[first.DELTA_STORAGE_KEY].lastFetchTime =
|
|
||||||
Date.now() - first.CACHE_TTL_MS - 1000;
|
|
||||||
|
|
||||||
const revived = restartWorker();
|
|
||||||
global.fetch = jest.fn(async () => ({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({ blacklist: ["refreshed-scam-xyz.com"] }),
|
|
||||||
}));
|
|
||||||
await revived.initPhishingList();
|
|
||||||
|
|
||||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
||||||
expect(revived.isPhishingDomain("refreshed-scam-xyz.com")).toBe(true);
|
|
||||||
expect(revived.isPhishingDomain("stale-scam-xyz.com")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a first start with nothing persisted fetches immediately", async () => {
|
|
||||||
const fresh = restartWorker();
|
|
||||||
global.fetch = jest.fn(async () => ({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({ blacklist: ["first-run-scam-xyz.com"] }),
|
|
||||||
}));
|
|
||||||
await fresh.initPhishingList();
|
|
||||||
|
|
||||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
||||||
expect(fresh.isPhishingDomain("first-run-scam-xyz.com")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("updatePhishingList honours the persisted timestamp on its own", async () => {
|
|
||||||
// The startup path calls updatePhishingList() directly, so it must
|
|
||||||
// load persisted state itself rather than relying on anything else
|
|
||||||
// having finished first.
|
|
||||||
const first = require("../src/shared/phishingDomains");
|
|
||||||
await first.loadConfig({ blacklist: ["alarm-tick-scam-xyz.com"] });
|
|
||||||
|
|
||||||
const revived = restartWorker();
|
|
||||||
global.fetch = jest.fn();
|
|
||||||
await revived.updatePhishingList();
|
|
||||||
|
|
||||||
expect(global.fetch).not.toHaveBeenCalled();
|
|
||||||
expect(revived.isPhishingDomain("alarm-tick-scam-xyz.com")).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// The alarm period alone must set the cadence. lastFetchTime is stamped when
|
|
||||||
// the fetch completes, so it lands one fetch latency after the alarm that
|
|
||||||
// caused it; a freshness guard timed to the alarm period therefore vetoes
|
|
||||||
// every scheduled tick and halves the real refresh rate. These tests measure
|
|
||||||
// the interval between fetches that actually happened.
|
|
||||||
describe("phishing refresh steady-state cadence", () => {
|
|
||||||
const { PHISHING_REFRESH_PERIOD_MINUTES } = require("../src/shared/alarms");
|
|
||||||
const PERIOD_MS = PHISHING_REFRESH_PERIOD_MINUTES * 60 * 1000;
|
|
||||||
|
|
||||||
let clockSpy;
|
|
||||||
let now;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
clearStorage();
|
|
||||||
jest.resetModules();
|
|
||||||
now = Date.UTC(2026, 0, 1, 0, 0, 0);
|
|
||||||
clockSpy = jest.spyOn(Date, "now").mockImplementation(() => now);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
clockSpy.mockRestore();
|
|
||||||
delete global.fetch;
|
|
||||||
});
|
|
||||||
|
|
||||||
function fetchStub(latencyMs, seen) {
|
|
||||||
return jest.fn(async () => {
|
|
||||||
seen.push(now);
|
|
||||||
// A network fetch takes time, and lastFetchTime is stamped after
|
|
||||||
// it, not when the alarm fired.
|
|
||||||
now += latencyMs;
|
|
||||||
return { ok: true, json: async () => ({ blacklist: [] }) };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test("ten alarm ticks produce ten fetches, one per period", async () => {
|
|
||||||
const fetchedAt = [];
|
|
||||||
global.fetch = fetchStub(5000, fetchedAt);
|
|
||||||
|
|
||||||
const startup = require("../src/shared/phishingDomains");
|
|
||||||
const T0 = now;
|
|
||||||
await startup.initPhishingList();
|
|
||||||
expect(fetchedAt).toEqual([T0]);
|
|
||||||
|
|
||||||
const TICKS = 10;
|
|
||||||
let tickAt = T0 + PERIOD_MS;
|
|
||||||
for (let i = 0; i < TICKS; i++) {
|
|
||||||
now = tickAt;
|
|
||||||
tickAt += PERIOD_MS;
|
|
||||||
// The browser wakes a terminated worker to deliver the alarm, so
|
|
||||||
// every tick starts from cold memory and the persisted record.
|
|
||||||
const revived = restartWorker();
|
|
||||||
await revived.refreshPhishingListOnSchedule();
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(fetchedAt).toHaveLength(TICKS + 1);
|
|
||||||
const intervals = fetchedAt.slice(1).map((t, i) => t - fetchedAt[i]);
|
|
||||||
expect(intervals).toEqual(new Array(TICKS).fill(PERIOD_MS));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the scheduled tick fetches whatever the last fetch's latency was", async () => {
|
|
||||||
// The alarm fires one period after the previous alarm, which is
|
|
||||||
// `latency` short of one period since the fetch it caused completed.
|
|
||||||
for (const latency of [200, 1000, 5000]) {
|
|
||||||
clearStorage();
|
|
||||||
jest.resetModules();
|
|
||||||
storageStore[DELTA_STORAGE_KEY] = {
|
|
||||||
blacklist: [],
|
|
||||||
lastFetchTime: now - PERIOD_MS + latency,
|
|
||||||
lastAttemptTime: now - PERIOD_MS,
|
|
||||||
};
|
|
||||||
const mod = require("../src/shared/phishingDomains");
|
|
||||||
const fetchedAt = [];
|
|
||||||
global.fetch = fetchStub(latency, fetchedAt);
|
|
||||||
|
|
||||||
await mod.refreshPhishingListOnSchedule();
|
|
||||||
expect(fetchedAt).toHaveLength(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a worker wake inside the cache window still does not fetch", async () => {
|
|
||||||
// The TTL is not removed, only taken off the scheduled path. Chrome
|
|
||||||
// revives the worker every ~30 seconds and every revival runs the
|
|
||||||
// startup path, so the TTL still has to keep that off the network.
|
|
||||||
storageStore[DELTA_STORAGE_KEY] = {
|
|
||||||
blacklist: [],
|
|
||||||
lastFetchTime: now - PERIOD_MS + 5000,
|
|
||||||
lastAttemptTime: now - PERIOD_MS,
|
|
||||||
};
|
|
||||||
const mod = require("../src/shared/phishingDomains");
|
|
||||||
global.fetch = jest.fn();
|
|
||||||
|
|
||||||
await mod.initPhishingList();
|
|
||||||
expect(global.fetch).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("phishing list timestamps that cannot be trusted", () => {
|
|
||||||
let clockSpy;
|
|
||||||
let now;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
clearStorage();
|
|
||||||
jest.resetModules();
|
|
||||||
now = Date.UTC(2026, 0, 1, 0, 0, 0);
|
|
||||||
clockSpy = jest.spyOn(Date, "now").mockImplementation(() => now);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
clockSpy.mockRestore();
|
|
||||||
delete global.fetch;
|
|
||||||
});
|
|
||||||
|
|
||||||
function okFetch() {
|
|
||||||
return jest.fn(async () => ({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({ blacklist: ["recovered-scam-xyz.com"] }),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// jest.resetModules() clears the call record of a jest.fn, and simulating
|
|
||||||
// a worker restart is exactly that call. Anything counted across restarts
|
|
||||||
// has to be counted outside the mock.
|
|
||||||
function countingFetch(counter, response) {
|
|
||||||
return async () => {
|
|
||||||
counter.calls++;
|
|
||||||
return response();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test("a lastFetchTime in the future is discarded rather than trusted", async () => {
|
|
||||||
// Clock skew or a restored profile backup writes one. Every guard
|
|
||||||
// measures `Date.now() - stamp` and only tests the lower bound, so a
|
|
||||||
// stamp a year ahead would suppress updates for a year, and now that
|
|
||||||
// the value is persisted it would outlive every worker.
|
|
||||||
storageStore[DELTA_STORAGE_KEY] = {
|
|
||||||
blacklist: ["poisoned-scam-xyz.com"],
|
|
||||||
lastFetchTime: now + 365 * 24 * 60 * 60 * 1000,
|
|
||||||
lastAttemptTime: 0,
|
|
||||||
};
|
|
||||||
const mod = require("../src/shared/phishingDomains");
|
|
||||||
global.fetch = okFetch();
|
|
||||||
|
|
||||||
await mod.initPhishingList();
|
|
||||||
|
|
||||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mod.isPhishingDomain("recovered-scam-xyz.com")).toBe(true);
|
|
||||||
// And the record it leaves behind is sane, so recovery is permanent.
|
|
||||||
expect(
|
|
||||||
storageStore[DELTA_STORAGE_KEY].lastFetchTime,
|
|
||||||
).toBeLessThanOrEqual(now);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a lastAttemptTime in the future does not suppress the retry", async () => {
|
|
||||||
storageStore[DELTA_STORAGE_KEY] = {
|
|
||||||
lastAttemptTime: now + 365 * 24 * 60 * 60 * 1000,
|
|
||||||
};
|
|
||||||
const mod = require("../src/shared/phishingDomains");
|
|
||||||
global.fetch = okFetch();
|
|
||||||
|
|
||||||
await mod.initPhishingList();
|
|
||||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an oversized delta does not re-download on every worker wake", async () => {
|
|
||||||
// The delta and its freshness claim are both dropped, which is right,
|
|
||||||
// but nothing then says a fetch just happened. Chrome cycles the
|
|
||||||
// worker roughly every 30 seconds idle, so without the attempt stamp
|
|
||||||
// this is a full blocklist download per wake, forever.
|
|
||||||
const huge = [];
|
|
||||||
for (let i = 0; i < 20000; i++) {
|
|
||||||
huge.push(`oversize-scam-${i}-xyzxyzxyzxyzxyz.com`);
|
|
||||||
}
|
|
||||||
const counter = { calls: 0 };
|
|
||||||
global.fetch = countingFetch(counter, () => ({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({ blacklist: huge }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
for (let wake = 0; wake < 4; wake++) {
|
|
||||||
const revived = restartWorker();
|
|
||||||
await revived.initPhishingList();
|
|
||||||
now += 30 * 1000; // idle timeout, worker torn down and revived
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(counter.calls).toBe(1);
|
|
||||||
expect(storageStore[DELTA_STORAGE_KEY].blacklist).toBeUndefined();
|
|
||||||
expect(typeof storageStore[DELTA_STORAGE_KEY].lastAttemptTime).toBe(
|
|
||||||
"number",
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a failing fetch is not retried on every worker wake either", async () => {
|
test("a different digest width throws", () => {
|
||||||
const counter = { calls: 0 };
|
expect(() => loadWith({ ...GOOD, hashHexChars: 8 })).toThrow(
|
||||||
global.fetch = countingFetch(counter, () => ({
|
/hex characters per entry/,
|
||||||
ok: false,
|
);
|
||||||
status: 503,
|
|
||||||
}));
|
|
||||||
|
|
||||||
for (let wake = 0; wake < 4; wake++) {
|
|
||||||
const revived = restartWorker();
|
|
||||||
await revived.initPhishingList();
|
|
||||||
now += 30 * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(counter.calls).toBe(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the retry floor expires, so a failure is not permanent", async () => {
|
test("a count that does not match the string length throws", () => {
|
||||||
const {
|
expect(() => loadWith({ ...GOOD, count: 3 })).toThrow(
|
||||||
MIN_FETCH_ATTEMPT_INTERVAL_MS,
|
/which is not the/,
|
||||||
} = require("../src/shared/phishingDomains");
|
);
|
||||||
const counter = { calls: 0 };
|
|
||||||
global.fetch = countingFetch(counter, () => ({
|
|
||||||
ok: false,
|
|
||||||
status: 503,
|
|
||||||
}));
|
|
||||||
|
|
||||||
await restartWorker().initPhishingList();
|
|
||||||
expect(counter.calls).toBe(1);
|
|
||||||
|
|
||||||
// Still inside the floor: no retry.
|
|
||||||
now += MIN_FETCH_ATTEMPT_INTERVAL_MS - 1000;
|
|
||||||
await restartWorker().initPhishingList();
|
|
||||||
expect(counter.calls).toBe(1);
|
|
||||||
|
|
||||||
// Past it: the extension goes back to the network.
|
|
||||||
now += 2000;
|
|
||||||
await restartWorker().initPhishingList();
|
|
||||||
expect(counter.calls).toBe(2);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the scheduled tick ignores the retry floor", async () => {
|
test("a missing hashes string throws", () => {
|
||||||
// The alarm period is far above the floor, but the floor exists to
|
expect(() => loadWith({ ...GOOD, hashes: undefined })).toThrow(
|
||||||
// throttle wakes, not the schedule.
|
/no hashes string/,
|
||||||
storageStore[DELTA_STORAGE_KEY] = { lastAttemptTime: now - 1000 };
|
);
|
||||||
const mod = require("../src/shared/phishingDomains");
|
});
|
||||||
global.fetch = okFetch();
|
|
||||||
|
|
||||||
await mod.refreshPhishingListOnSchedule();
|
test("an empty artifact throws rather than matching nothing", () => {
|
||||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
expect(() => loadWith({ ...GOOD, count: 0, hashes: "" })).toThrow(
|
||||||
|
/entry count/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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
|
"0xab5eb14c09d416f0ac63661e57edb7aecdb9befa", // Metronome Synth USD
|
||||||
],
|
],
|
||||||
MUSD: [
|
MUSD: [
|
||||||
"0xaca92e438df0b2401ff60da7e4337b687a2435da", // MetaMask USD
|
"0xaca92e438df0b2401ff60da7e4337b687a2435da",
|
||||||
"0xdd468a1ddc392dcdbef6db6e34e89aa338f9f186", // Mezo USD
|
"0xdd468a1ddc392dcdbef6db6e34e89aa338f9f186", // Mezo USD
|
||||||
],
|
],
|
||||||
JPYC: [
|
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 uniswap = require("../src/shared/uniswap");
|
||||||
|
|
||||||
const ROUTER_ADDR = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
|
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"
|
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz#0eaf705c941a218a43dba8e09f1df1d6cd2f1f17"
|
||||||
integrity sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==
|
integrity sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==
|
||||||
|
|
||||||
|
"@eslint-community/eslint-utils@^4.8.0":
|
||||||
|
version "4.10.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz#8911bd72b2c3640a543609e0400b8c4d2e7e7cb6"
|
||||||
|
integrity sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==
|
||||||
|
dependencies:
|
||||||
|
eslint-visitor-keys "^3.4.3"
|
||||||
|
|
||||||
|
"@eslint-community/regexpp@^4.12.2":
|
||||||
|
version "4.12.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b"
|
||||||
|
integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==
|
||||||
|
|
||||||
|
"@eslint/config-array@^0.23.5":
|
||||||
|
version "0.23.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.23.5.tgz#56e86d243049195d8acc0c06a1b3dfdc3fa3de95"
|
||||||
|
integrity sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==
|
||||||
|
dependencies:
|
||||||
|
"@eslint/object-schema" "^3.0.5"
|
||||||
|
debug "^4.3.1"
|
||||||
|
minimatch "^10.2.4"
|
||||||
|
|
||||||
|
"@eslint/config-helpers@^0.7.0":
|
||||||
|
version "0.7.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.7.0.tgz#09ee4aa07b73f059ec2d4c74bf4b2ff02b322377"
|
||||||
|
integrity sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==
|
||||||
|
dependencies:
|
||||||
|
"@eslint/core" "^1.2.1"
|
||||||
|
|
||||||
|
"@eslint/core@^1.2.1":
|
||||||
|
version "1.2.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.2.1.tgz#c1da7cd1b82fa8787f98b5629fb811848a1b63ce"
|
||||||
|
integrity sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==
|
||||||
|
dependencies:
|
||||||
|
"@types/json-schema" "^7.0.15"
|
||||||
|
|
||||||
|
"@eslint/js@10.0.1":
|
||||||
|
version "10.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-10.0.1.tgz#1e8a876f50117af8ab67e47d5ad94d38d6622583"
|
||||||
|
integrity sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==
|
||||||
|
|
||||||
|
"@eslint/object-schema@^3.0.5":
|
||||||
|
version "3.0.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-3.0.5.tgz#88e9bf4d11d2b19c082e78ebe7ce88724a5eb091"
|
||||||
|
integrity sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==
|
||||||
|
|
||||||
|
"@eslint/plugin-kit@^0.7.2":
|
||||||
|
version "0.7.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz#4b0962f3f2c7ce8bc98b3ecfe34525c09d2cb729"
|
||||||
|
integrity sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==
|
||||||
|
dependencies:
|
||||||
|
"@eslint/core" "^1.2.1"
|
||||||
|
levn "^0.4.1"
|
||||||
|
|
||||||
|
"@humanfs/core@^0.19.2":
|
||||||
|
version "0.19.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.2.tgz#a8272ca03b2acf492670222b2320b6c421bfde60"
|
||||||
|
integrity sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==
|
||||||
|
dependencies:
|
||||||
|
"@humanfs/types" "^0.15.0"
|
||||||
|
|
||||||
|
"@humanfs/node@^0.16.6":
|
||||||
|
version "0.16.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/@humanfs/node/-/node-0.16.8.tgz#8f800cccc13f4f8cd3116e2d9c0a94939da3e3ed"
|
||||||
|
integrity sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==
|
||||||
|
dependencies:
|
||||||
|
"@humanfs/core" "^0.19.2"
|
||||||
|
"@humanfs/types" "^0.15.0"
|
||||||
|
"@humanwhocodes/retry" "^0.4.0"
|
||||||
|
|
||||||
|
"@humanfs/types@^0.15.0":
|
||||||
|
version "0.15.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@humanfs/types/-/types-0.15.0.tgz#f2a09f62012390b2bff3fc6fb248ddec8c09a090"
|
||||||
|
integrity sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==
|
||||||
|
|
||||||
|
"@humanwhocodes/module-importer@^1.0.1":
|
||||||
|
version "1.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c"
|
||||||
|
integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==
|
||||||
|
|
||||||
|
"@humanwhocodes/retry@^0.4.0", "@humanwhocodes/retry@^0.4.2":
|
||||||
|
version "0.4.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba"
|
||||||
|
integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==
|
||||||
|
|
||||||
"@isaacs/cliui@^8.0.2":
|
"@isaacs/cliui@^8.0.2":
|
||||||
version "8.0.2"
|
version "8.0.2"
|
||||||
resolved "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz"
|
resolved "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz"
|
||||||
@@ -1008,6 +1092,16 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@babel/types" "^7.28.2"
|
"@babel/types" "^7.28.2"
|
||||||
|
|
||||||
|
"@types/esrecurse@^4.3.1":
|
||||||
|
version "4.3.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/esrecurse/-/esrecurse-4.3.1.tgz#6f636af962fbe6191b830bd676ba5986926bccec"
|
||||||
|
integrity sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==
|
||||||
|
|
||||||
|
"@types/estree@^1.0.6", "@types/estree@^1.0.8":
|
||||||
|
version "1.0.9"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.9.tgz#cf3f0e876d7bee15a93ab925b82bf570a3904a24"
|
||||||
|
integrity sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==
|
||||||
|
|
||||||
"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.1", "@types/istanbul-lib-coverage@^2.0.6":
|
"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.1", "@types/istanbul-lib-coverage@^2.0.6":
|
||||||
version "2.0.6"
|
version "2.0.6"
|
||||||
resolved "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz"
|
resolved "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz"
|
||||||
@@ -1027,6 +1121,11 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@types/istanbul-lib-report" "*"
|
"@types/istanbul-lib-report" "*"
|
||||||
|
|
||||||
|
"@types/json-schema@^7.0.15":
|
||||||
|
version "7.0.15"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
|
||||||
|
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
|
||||||
|
|
||||||
"@types/node@*", "@types/node@22.7.5":
|
"@types/node@*", "@types/node@22.7.5":
|
||||||
version "22.7.5"
|
version "22.7.5"
|
||||||
resolved "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz"
|
resolved "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz"
|
||||||
@@ -1153,11 +1252,31 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz#538b1e103bf8d9864e7b85cc96fa8d6fb6c40777"
|
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz#538b1e103bf8d9864e7b85cc96fa8d6fb6c40777"
|
||||||
integrity sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==
|
integrity sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==
|
||||||
|
|
||||||
|
acorn-jsx@^5.3.2:
|
||||||
|
version "5.3.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
|
||||||
|
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
|
||||||
|
|
||||||
|
acorn@^8.16.0:
|
||||||
|
version "8.18.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.18.0.tgz#4faf01b2d6d326bfeed97aea1f52220b5f4c1940"
|
||||||
|
integrity sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==
|
||||||
|
|
||||||
aes-js@4.0.0-beta.5:
|
aes-js@4.0.0-beta.5:
|
||||||
version "4.0.0-beta.5"
|
version "4.0.0-beta.5"
|
||||||
resolved "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz"
|
resolved "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz"
|
||||||
integrity sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==
|
integrity sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==
|
||||||
|
|
||||||
|
ajv@^6.14.0:
|
||||||
|
version "6.15.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.15.0.tgz#07e982c74626167aa7a2495c53817892d7139492"
|
||||||
|
integrity sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==
|
||||||
|
dependencies:
|
||||||
|
fast-deep-equal "^3.1.1"
|
||||||
|
fast-json-stable-stringify "^2.0.0"
|
||||||
|
json-schema-traverse "^0.4.1"
|
||||||
|
uri-js "^4.2.2"
|
||||||
|
|
||||||
ansi-escapes@^4.3.2:
|
ansi-escapes@^4.3.2:
|
||||||
version "4.3.2"
|
version "4.3.2"
|
||||||
resolved "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz"
|
resolved "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz"
|
||||||
@@ -1297,6 +1416,13 @@ brace-expansion@^5.0.2:
|
|||||||
dependencies:
|
dependencies:
|
||||||
balanced-match "^4.0.2"
|
balanced-match "^4.0.2"
|
||||||
|
|
||||||
|
brace-expansion@^5.0.8:
|
||||||
|
version "5.0.9"
|
||||||
|
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.9.tgz#7c72438809b5fa5babf54199a1f1c281a6984fcf"
|
||||||
|
integrity sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==
|
||||||
|
dependencies:
|
||||||
|
balanced-match "^4.0.2"
|
||||||
|
|
||||||
braces@^3.0.3:
|
braces@^3.0.3:
|
||||||
version "3.0.3"
|
version "3.0.3"
|
||||||
resolved "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz"
|
resolved "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz"
|
||||||
@@ -1429,7 +1555,7 @@ cross-spawn@^7.0.3, cross-spawn@^7.0.6:
|
|||||||
shebang-command "^2.0.0"
|
shebang-command "^2.0.0"
|
||||||
which "^2.0.1"
|
which "^2.0.1"
|
||||||
|
|
||||||
debug@^4.1.0, debug@^4.1.1, debug@^4.3.1:
|
debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2:
|
||||||
version "4.4.3"
|
version "4.4.3"
|
||||||
resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz"
|
resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz"
|
||||||
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
|
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
|
||||||
@@ -1446,6 +1572,11 @@ dedent@^1.6.0:
|
|||||||
resolved "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz"
|
resolved "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz"
|
||||||
integrity sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==
|
integrity sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==
|
||||||
|
|
||||||
|
deep-is@^0.1.3:
|
||||||
|
version "0.1.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
|
||||||
|
integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
|
||||||
|
|
||||||
deepmerge@^4.3.1:
|
deepmerge@^4.3.1:
|
||||||
version "4.3.1"
|
version "4.3.1"
|
||||||
resolved "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz"
|
resolved "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz"
|
||||||
@@ -1548,11 +1679,105 @@ escape-string-regexp@^2.0.0:
|
|||||||
resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz"
|
resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz"
|
||||||
integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==
|
integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==
|
||||||
|
|
||||||
|
escape-string-regexp@^4.0.0:
|
||||||
|
version "4.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
|
||||||
|
integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
|
||||||
|
|
||||||
|
eslint-scope@^9.1.2:
|
||||||
|
version "9.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-9.1.2.tgz#b9de6ace2fab1cff24d2e58d85b74c8fcea39802"
|
||||||
|
integrity sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==
|
||||||
|
dependencies:
|
||||||
|
"@types/esrecurse" "^4.3.1"
|
||||||
|
"@types/estree" "^1.0.8"
|
||||||
|
esrecurse "^4.3.0"
|
||||||
|
estraverse "^5.2.0"
|
||||||
|
|
||||||
|
eslint-visitor-keys@^3.4.3:
|
||||||
|
version "3.4.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800"
|
||||||
|
integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==
|
||||||
|
|
||||||
|
eslint-visitor-keys@^5.0.1:
|
||||||
|
version "5.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be"
|
||||||
|
integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==
|
||||||
|
|
||||||
|
eslint@10.8.1:
|
||||||
|
version "10.8.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.8.1.tgz#fb37d514c19b6dd5b2d6b70169fd26fddfa97967"
|
||||||
|
integrity sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==
|
||||||
|
dependencies:
|
||||||
|
"@eslint-community/eslint-utils" "^4.8.0"
|
||||||
|
"@eslint-community/regexpp" "^4.12.2"
|
||||||
|
"@eslint/config-array" "^0.23.5"
|
||||||
|
"@eslint/config-helpers" "^0.7.0"
|
||||||
|
"@eslint/core" "^1.2.1"
|
||||||
|
"@eslint/plugin-kit" "^0.7.2"
|
||||||
|
"@humanfs/node" "^0.16.6"
|
||||||
|
"@humanwhocodes/module-importer" "^1.0.1"
|
||||||
|
"@humanwhocodes/retry" "^0.4.2"
|
||||||
|
"@types/estree" "^1.0.6"
|
||||||
|
ajv "^6.14.0"
|
||||||
|
cross-spawn "^7.0.6"
|
||||||
|
debug "^4.3.2"
|
||||||
|
escape-string-regexp "^4.0.0"
|
||||||
|
eslint-scope "^9.1.2"
|
||||||
|
eslint-visitor-keys "^5.0.1"
|
||||||
|
espree "^11.2.0"
|
||||||
|
esquery "^1.7.0"
|
||||||
|
esutils "^2.0.2"
|
||||||
|
fast-deep-equal "^3.1.3"
|
||||||
|
file-entry-cache "^8.0.0"
|
||||||
|
find-up "^5.0.0"
|
||||||
|
glob-parent "^6.0.2"
|
||||||
|
ignore "^5.2.0"
|
||||||
|
imurmurhash "^0.1.4"
|
||||||
|
is-glob "^4.0.0"
|
||||||
|
json-stable-stringify-without-jsonify "^1.0.1"
|
||||||
|
minimatch "^10.2.5"
|
||||||
|
natural-compare "^1.4.0"
|
||||||
|
optionator "^0.9.3"
|
||||||
|
|
||||||
|
espree@^11.2.0:
|
||||||
|
version "11.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/espree/-/espree-11.2.0.tgz#01d5e47dc332aaba3059008362454a8cc34ccaa5"
|
||||||
|
integrity sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==
|
||||||
|
dependencies:
|
||||||
|
acorn "^8.16.0"
|
||||||
|
acorn-jsx "^5.3.2"
|
||||||
|
eslint-visitor-keys "^5.0.1"
|
||||||
|
|
||||||
esprima@^4.0.0:
|
esprima@^4.0.0:
|
||||||
version "4.0.1"
|
version "4.0.1"
|
||||||
resolved "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz"
|
resolved "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz"
|
||||||
integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
|
integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
|
||||||
|
|
||||||
|
esquery@^1.7.0:
|
||||||
|
version "1.7.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.7.0.tgz#08d048f261f0ddedb5bae95f46809463d9c9496d"
|
||||||
|
integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==
|
||||||
|
dependencies:
|
||||||
|
estraverse "^5.1.0"
|
||||||
|
|
||||||
|
esrecurse@^4.3.0:
|
||||||
|
version "4.3.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921"
|
||||||
|
integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==
|
||||||
|
dependencies:
|
||||||
|
estraverse "^5.2.0"
|
||||||
|
|
||||||
|
estraverse@^5.1.0, estraverse@^5.2.0:
|
||||||
|
version "5.3.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123"
|
||||||
|
integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==
|
||||||
|
|
||||||
|
esutils@^2.0.2:
|
||||||
|
version "2.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
|
||||||
|
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
|
||||||
|
|
||||||
ethereum-blockies-base64@^1.0.2:
|
ethereum-blockies-base64@^1.0.2:
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
resolved "https://registry.npmjs.org/ethereum-blockies-base64/-/ethereum-blockies-base64-1.0.2.tgz"
|
resolved "https://registry.npmjs.org/ethereum-blockies-base64/-/ethereum-blockies-base64-1.0.2.tgz"
|
||||||
@@ -1605,11 +1830,21 @@ expect@30.2.0:
|
|||||||
jest-mock "30.2.0"
|
jest-mock "30.2.0"
|
||||||
jest-util "30.2.0"
|
jest-util "30.2.0"
|
||||||
|
|
||||||
fast-json-stable-stringify@^2.1.0:
|
fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
|
||||||
|
version "3.1.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
|
||||||
|
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
|
||||||
|
|
||||||
|
fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0:
|
||||||
version "2.1.0"
|
version "2.1.0"
|
||||||
resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz"
|
resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz"
|
||||||
integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
|
integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
|
||||||
|
|
||||||
|
fast-levenshtein@^2.0.6:
|
||||||
|
version "2.0.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
|
||||||
|
integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==
|
||||||
|
|
||||||
fb-watchman@^2.0.2:
|
fb-watchman@^2.0.2:
|
||||||
version "2.0.2"
|
version "2.0.2"
|
||||||
resolved "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz"
|
resolved "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz"
|
||||||
@@ -1617,6 +1852,13 @@ fb-watchman@^2.0.2:
|
|||||||
dependencies:
|
dependencies:
|
||||||
bser "2.1.1"
|
bser "2.1.1"
|
||||||
|
|
||||||
|
file-entry-cache@^8.0.0:
|
||||||
|
version "8.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f"
|
||||||
|
integrity sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==
|
||||||
|
dependencies:
|
||||||
|
flat-cache "^4.0.0"
|
||||||
|
|
||||||
fill-range@^7.1.1:
|
fill-range@^7.1.1:
|
||||||
version "7.1.1"
|
version "7.1.1"
|
||||||
resolved "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz"
|
resolved "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz"
|
||||||
@@ -1632,6 +1874,27 @@ find-up@^4.0.0, find-up@^4.1.0:
|
|||||||
locate-path "^5.0.0"
|
locate-path "^5.0.0"
|
||||||
path-exists "^4.0.0"
|
path-exists "^4.0.0"
|
||||||
|
|
||||||
|
find-up@^5.0.0:
|
||||||
|
version "5.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc"
|
||||||
|
integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==
|
||||||
|
dependencies:
|
||||||
|
locate-path "^6.0.0"
|
||||||
|
path-exists "^4.0.0"
|
||||||
|
|
||||||
|
flat-cache@^4.0.0:
|
||||||
|
version "4.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-4.0.1.tgz#0ece39fcb14ee012f4b0410bd33dd9c1f011127c"
|
||||||
|
integrity sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==
|
||||||
|
dependencies:
|
||||||
|
flatted "^3.2.9"
|
||||||
|
keyv "^4.5.4"
|
||||||
|
|
||||||
|
flatted@^3.2.9:
|
||||||
|
version "3.4.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.4.tgz#aeeca2a506303f0cee61c59e6c9f2a88d2f29fc6"
|
||||||
|
integrity sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==
|
||||||
|
|
||||||
foreground-child@^3.1.0:
|
foreground-child@^3.1.0:
|
||||||
version "3.3.1"
|
version "3.3.1"
|
||||||
resolved "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz"
|
resolved "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz"
|
||||||
@@ -1670,6 +1933,13 @@ get-stream@^6.0.0:
|
|||||||
resolved "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz"
|
resolved "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz"
|
||||||
integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==
|
integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==
|
||||||
|
|
||||||
|
glob-parent@^6.0.2:
|
||||||
|
version "6.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3"
|
||||||
|
integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==
|
||||||
|
dependencies:
|
||||||
|
is-glob "^4.0.3"
|
||||||
|
|
||||||
glob@^10.3.10:
|
glob@^10.3.10:
|
||||||
version "10.5.0"
|
version "10.5.0"
|
||||||
resolved "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz"
|
resolved "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz"
|
||||||
@@ -1694,6 +1964,11 @@ glob@^7.1.4:
|
|||||||
once "^1.3.0"
|
once "^1.3.0"
|
||||||
path-is-absolute "^1.0.0"
|
path-is-absolute "^1.0.0"
|
||||||
|
|
||||||
|
globals@17.11.0:
|
||||||
|
version "17.11.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/globals/-/globals-17.11.0.tgz#d643485bb30220d7751e511cf4f68c73d3870d87"
|
||||||
|
integrity sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==
|
||||||
|
|
||||||
graceful-fs@^4.2.11, graceful-fs@^4.2.4:
|
graceful-fs@^4.2.11, graceful-fs@^4.2.4:
|
||||||
version "4.2.11"
|
version "4.2.11"
|
||||||
resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz"
|
resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz"
|
||||||
@@ -1714,6 +1989,11 @@ human-signals@^2.1.0:
|
|||||||
resolved "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz"
|
resolved "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz"
|
||||||
integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==
|
integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==
|
||||||
|
|
||||||
|
ignore@^5.2.0:
|
||||||
|
version "5.3.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5"
|
||||||
|
integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==
|
||||||
|
|
||||||
import-local@^3.2.0:
|
import-local@^3.2.0:
|
||||||
version "3.2.0"
|
version "3.2.0"
|
||||||
resolved "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz"
|
resolved "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz"
|
||||||
@@ -1760,7 +2040,7 @@ is-generator-fn@^2.1.0:
|
|||||||
resolved "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz"
|
resolved "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz"
|
||||||
integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==
|
integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==
|
||||||
|
|
||||||
is-glob@^4.0.3:
|
is-glob@^4.0.0, is-glob@^4.0.3:
|
||||||
version "4.0.3"
|
version "4.0.3"
|
||||||
resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz"
|
resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz"
|
||||||
integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
|
integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
|
||||||
@@ -2212,21 +2492,51 @@ jsesc@^3.0.2:
|
|||||||
resolved "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz"
|
resolved "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz"
|
||||||
integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==
|
integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==
|
||||||
|
|
||||||
|
json-buffer@3.0.1:
|
||||||
|
version "3.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13"
|
||||||
|
integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==
|
||||||
|
|
||||||
json-parse-even-better-errors@^2.3.0:
|
json-parse-even-better-errors@^2.3.0:
|
||||||
version "2.3.1"
|
version "2.3.1"
|
||||||
resolved "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz"
|
resolved "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz"
|
||||||
integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==
|
integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==
|
||||||
|
|
||||||
|
json-schema-traverse@^0.4.1:
|
||||||
|
version "0.4.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
|
||||||
|
integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==
|
||||||
|
|
||||||
|
json-stable-stringify-without-jsonify@^1.0.1:
|
||||||
|
version "1.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
|
||||||
|
integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==
|
||||||
|
|
||||||
json5@^2.2.3:
|
json5@^2.2.3:
|
||||||
version "2.2.3"
|
version "2.2.3"
|
||||||
resolved "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz"
|
resolved "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz"
|
||||||
integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
|
integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
|
||||||
|
|
||||||
|
keyv@^4.5.4:
|
||||||
|
version "4.5.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93"
|
||||||
|
integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==
|
||||||
|
dependencies:
|
||||||
|
json-buffer "3.0.1"
|
||||||
|
|
||||||
leven@^3.1.0:
|
leven@^3.1.0:
|
||||||
version "3.1.0"
|
version "3.1.0"
|
||||||
resolved "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz"
|
resolved "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz"
|
||||||
integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==
|
integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==
|
||||||
|
|
||||||
|
levn@^0.4.1:
|
||||||
|
version "0.4.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade"
|
||||||
|
integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==
|
||||||
|
dependencies:
|
||||||
|
prelude-ls "^1.2.1"
|
||||||
|
type-check "~0.4.0"
|
||||||
|
|
||||||
libsodium-sumo@^0.8.0:
|
libsodium-sumo@^0.8.0:
|
||||||
version "0.8.2"
|
version "0.8.2"
|
||||||
resolved "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.8.2.tgz"
|
resolved "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.8.2.tgz"
|
||||||
@@ -2325,6 +2635,13 @@ locate-path@^5.0.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
p-locate "^4.1.0"
|
p-locate "^4.1.0"
|
||||||
|
|
||||||
|
locate-path@^6.0.0:
|
||||||
|
version "6.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286"
|
||||||
|
integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==
|
||||||
|
dependencies:
|
||||||
|
p-locate "^5.0.0"
|
||||||
|
|
||||||
lru-cache@^10.2.0:
|
lru-cache@^10.2.0:
|
||||||
version "10.4.3"
|
version "10.4.3"
|
||||||
resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz"
|
resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz"
|
||||||
@@ -2376,6 +2693,13 @@ mimic-fn@^2.1.0:
|
|||||||
resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz"
|
resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz"
|
||||||
integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
|
integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
|
||||||
|
|
||||||
|
minimatch@^10.2.4, minimatch@^10.2.5:
|
||||||
|
version "10.2.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.6.tgz#fd956bbe0b77241e9f15ac5dccb1c638060968ef"
|
||||||
|
integrity sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==
|
||||||
|
dependencies:
|
||||||
|
brace-expansion "^5.0.8"
|
||||||
|
|
||||||
minimatch@^3.0.4, minimatch@^3.1.1:
|
minimatch@^3.0.4, minimatch@^3.1.1:
|
||||||
version "3.1.3"
|
version "3.1.3"
|
||||||
resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz"
|
resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz"
|
||||||
@@ -2456,6 +2780,18 @@ onetime@^5.1.2:
|
|||||||
dependencies:
|
dependencies:
|
||||||
mimic-fn "^2.1.0"
|
mimic-fn "^2.1.0"
|
||||||
|
|
||||||
|
optionator@^0.9.3:
|
||||||
|
version "0.9.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734"
|
||||||
|
integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==
|
||||||
|
dependencies:
|
||||||
|
deep-is "^0.1.3"
|
||||||
|
fast-levenshtein "^2.0.6"
|
||||||
|
levn "^0.4.1"
|
||||||
|
prelude-ls "^1.2.1"
|
||||||
|
type-check "^0.4.0"
|
||||||
|
word-wrap "^1.2.5"
|
||||||
|
|
||||||
p-limit@^2.2.0:
|
p-limit@^2.2.0:
|
||||||
version "2.3.0"
|
version "2.3.0"
|
||||||
resolved "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz"
|
resolved "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz"
|
||||||
@@ -2463,7 +2799,7 @@ p-limit@^2.2.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
p-try "^2.0.0"
|
p-try "^2.0.0"
|
||||||
|
|
||||||
p-limit@^3.1.0:
|
p-limit@^3.0.2, p-limit@^3.1.0:
|
||||||
version "3.1.0"
|
version "3.1.0"
|
||||||
resolved "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz"
|
resolved "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz"
|
||||||
integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==
|
integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==
|
||||||
@@ -2477,6 +2813,13 @@ p-locate@^4.1.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
p-limit "^2.2.0"
|
p-limit "^2.2.0"
|
||||||
|
|
||||||
|
p-locate@^5.0.0:
|
||||||
|
version "5.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834"
|
||||||
|
integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==
|
||||||
|
dependencies:
|
||||||
|
p-limit "^3.0.2"
|
||||||
|
|
||||||
p-try@^2.0.0:
|
p-try@^2.0.0:
|
||||||
version "2.2.0"
|
version "2.2.0"
|
||||||
resolved "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz"
|
resolved "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz"
|
||||||
@@ -2562,6 +2905,11 @@ pnglib@0.0.1:
|
|||||||
resolved "https://registry.npmjs.org/pnglib/-/pnglib-0.0.1.tgz"
|
resolved "https://registry.npmjs.org/pnglib/-/pnglib-0.0.1.tgz"
|
||||||
integrity sha512-95ChzOoYLOPIyVmL+Y6X+abKGXUJlvOVLkB1QQkyXl7Uczc6FElUy/x01NS7r2GX6GRezloO/ecCX9h4U9KadA==
|
integrity sha512-95ChzOoYLOPIyVmL+Y6X+abKGXUJlvOVLkB1QQkyXl7Uczc6FElUy/x01NS7r2GX6GRezloO/ecCX9h4U9KadA==
|
||||||
|
|
||||||
|
prelude-ls@^1.2.1:
|
||||||
|
version "1.2.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
|
||||||
|
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
|
||||||
|
|
||||||
prettier@^3.8.1:
|
prettier@^3.8.1:
|
||||||
version "3.8.1"
|
version "3.8.1"
|
||||||
resolved "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz"
|
resolved "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz"
|
||||||
@@ -2576,6 +2924,11 @@ pretty-format@30.2.0:
|
|||||||
ansi-styles "^5.2.0"
|
ansi-styles "^5.2.0"
|
||||||
react-is "^18.3.1"
|
react-is "^18.3.1"
|
||||||
|
|
||||||
|
punycode@^2.1.0:
|
||||||
|
version "2.3.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
|
||||||
|
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
||||||
|
|
||||||
pure-rand@^7.0.0:
|
pure-rand@^7.0.0:
|
||||||
version "7.0.1"
|
version "7.0.1"
|
||||||
resolved "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz"
|
resolved "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz"
|
||||||
@@ -2822,6 +3175,13 @@ tslib@^2.8.1:
|
|||||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
|
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
|
||||||
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
|
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
|
||||||
|
|
||||||
|
type-check@^0.4.0, type-check@~0.4.0:
|
||||||
|
version "0.4.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"
|
||||||
|
integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==
|
||||||
|
dependencies:
|
||||||
|
prelude-ls "^1.2.1"
|
||||||
|
|
||||||
type-detect@4.0.8:
|
type-detect@4.0.8:
|
||||||
version "4.0.8"
|
version "4.0.8"
|
||||||
resolved "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz"
|
resolved "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz"
|
||||||
@@ -2872,6 +3232,13 @@ update-browserslist-db@^1.2.0:
|
|||||||
escalade "^3.2.0"
|
escalade "^3.2.0"
|
||||||
picocolors "^1.1.1"
|
picocolors "^1.1.1"
|
||||||
|
|
||||||
|
uri-js@^4.2.2:
|
||||||
|
version "4.4.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e"
|
||||||
|
integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==
|
||||||
|
dependencies:
|
||||||
|
punycode "^2.1.0"
|
||||||
|
|
||||||
v8-to-istanbul@^9.0.1:
|
v8-to-istanbul@^9.0.1:
|
||||||
version "9.3.0"
|
version "9.3.0"
|
||||||
resolved "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz"
|
resolved "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz"
|
||||||
@@ -2900,6 +3267,11 @@ which@^2.0.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
isexe "^2.0.0"
|
isexe "^2.0.0"
|
||||||
|
|
||||||
|
word-wrap@^1.2.5:
|
||||||
|
version "1.2.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
|
||||||
|
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
|
||||||
|
|
||||||
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
|
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
|
||||||
version "7.0.0"
|
version "7.0.0"
|
||||||
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz"
|
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz"
|
||||||
|
|||||||
Reference in New Issue
Block a user