Compare commits

..

1 Commits

Author SHA1 Message Date
ae4d211c11 test: drive the Settings screen in a browser and guard every popup element id (closes #229)
All checks were successful
check / check (push) Successful in 29s
Nothing exercised the Settings view in a browser, and jest runs in the
node environment with no DOM, so the densest run of $("...") lookups in
the codebase was unverified at runtime. A wrong id is valid JavaScript
naming a defined function: $() returns null and the next property access
throws, which inside a view's init() aborts the rest of the popup's
init() and leaves every screen blank.

Two halves, because they catch different things.

The e2e suite (tests/e2e/run.js) gains six cases between the address
removal and dust threshold sections. They assert the About well and the
wallet list were actually written — show() populates those last, so
reading them back proves the whole of show() ran rather than just enough
of it to unhide the section — that the four Token Spam Protection
controls are real input[type=checkbox] elements defaulted on, and that
the theme and network selectors offer exactly the choices
src/shared/networks.js and index.html define while carrying the
persisted value. One filter is then toggled off and back on across a
popup reopen each way, which runs the change handler, saveState(),
loadState() and the init() assignment rather than only looking at the
screen. Each group records a coverage key and a final case demands the
exact set, so a section that silently stopped running reddens the suite
instead of shrinking it.

tests/popupElementIds.test.js is the general half and needs no browser,
so jest picks it up and it runs in make check: every literal id reached
through $(), document.getElementById(), showError()/hideError() and
showView() must exist in src/popup/index.html, no id in index.html may
be defined twice, and the scan asserts it found the code and the markup
so it cannot pass by covering nothing. Only literal arguments are
resolvable statically; $(containerId) and a lookup naming the wrong
existing element are the browser suites' job, and README says so.

Demonstrated against three deliberate breaks. A typo'd id in
settings.js reddens both halves, the e2e run reporting
"pageerror: Cannot set properties of null (setting 'checked')" against
its first test. A handler bound to the wrong but existing element passes
the static guard and reddens only the new functional case. A typo in a
view no browser suite opens reddens only the static guard.
2026-08-17 06:21:27 +00:00
21 changed files with 245 additions and 1615 deletions

View File

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

137
README.md
View File

@@ -83,11 +83,10 @@ provide:
git pre-commit hook git pre-commit hook
- `script/projectname` — print the project name (used for the Docker image tag) - `script/projectname` — print the project name (used for the Docker image tag)
- `script/test` — run the test suite (jest) - `script/test` — run the test suite (jest)
- `script/test-e2e` — run the Chrome browser end-to-end suite (docker is the - `script/test-e2e` — run the Chrome browser end-to-end suite (docker required;
only prerequisite: it builds a pinned image that carries the repo and a fresh see [End-to-End Tests](#end-to-end-tests))
extension build, see [End-to-End Tests](#end-to-end-tests)) - `script/test-e2e-firefox` — run the Firefox browser end-to-end suite (docker
- `script/test-e2e-firefox` — run the Firefox browser end-to-end suite (same, required; builds its own pinned image, 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 the linter
- `script/fmt` — format all files (writes) - `script/fmt` — format all files (writes)
@@ -137,12 +136,11 @@ are outside `make check`.
`make test-e2e` builds `dist/chrome/` and drives the **real popup in a real `make test-e2e` builds `dist/chrome/` and drives the **real popup in a real
Chrome**, loaded as an unpacked MV3 extension inside a pinned Chrome**, loaded as an unpacked MV3 extension inside a pinned
`mcr.microsoft.com/playwright` container (pinned by digest in `mcr.microsoft.com/playwright` container (pinned by digest in `script/test-e2e`;
`tests/e2e/Dockerfile`, which is also where the extension is built; docker is docker is required and the suite fails loudly rather than skipping if it is
required and the suite fails loudly rather than skipping if it is unavailable). unavailable). The suite lives in `tests/e2e/` and is driven by
The suite lives in `tests/e2e/` and is driven by `playwright-core`, whose `playwright-core`, whose version must stay matched to the container's Playwright
version must stay matched to the container's Playwright version — the browsers version — the browsers ship inside the image.
ship inside the image.
It covers popup load, WebAssembly compilation under the shipped CSP (see It covers popup load, WebAssembly compilation under the shipped CSP (see
[Content Security Policy](#content-security-policy)), wallet creation through [Content Security Policy](#content-security-policy)), wallet creation through
@@ -159,23 +157,6 @@ 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
@@ -264,13 +245,11 @@ The suite lives in `tests/e2e/firefox/` and has **no npm dependencies at all**:
it is a small WebDriver client built on global `fetch` and `child_process` it is a small WebDriver client built on global `fetch` and `child_process`
against geckodriver's HTTP API. against geckodriver's HTTP API.
Both suites build their own image, each with the repo and a fresh extension Unlike the Chrome suite it builds its own container image rather than pulling a
build baked in; what differs is the base. The Chrome image layers those on top published one, because no published image carries both a pinned Firefox and a
of a published Playwright image, whereas this one is assembled from a `node` matching geckodriver. `tests/e2e/firefox/Dockerfile` pins all three external
base, because no published image carries both a pinned Firefox and a matching artifacts by digest — the `node` base image, the Firefox 153.0.3 tarball, and
geckodriver. `tests/e2e/firefox/Dockerfile` pins all three external artifacts by geckodriver 0.36.0 — and the Firefox version in particular must not float:
digest — the `node` base image, the Firefox 153.0.3 tarball, and geckodriver
0.36.0 — and the Firefox version in particular must not float:
`-remote-allow-system-access` is **mandatory** on 153 and was not on 142. `-remote-allow-system-access` is **mandatory** on 153 and was not on 142.
Without that flag, both navigating to `moz-extension://` and running Without that flag, both navigating to `moz-extension://` and running
chrome-context script fail with `unsupported operation`. The flag grants the chrome-context script fail with `unsupported operation`. The flag grants the
@@ -338,46 +317,9 @@ Two limits are worth knowing, both real differences from the Chrome suite:
Neither `make test-e2e` nor `make test-e2e-firefox` is part of `make check` or Neither `make test-e2e` nor `make test-e2e-firefox` is part of `make check` or
`make test`. `REPO_POLICIES.md` caps `make test` at 20 seconds and a browser `make test`. `REPO_POLICIES.md` caps `make test` at 20 seconds and a browser
suite does not fit; nothing in `tests/e2e/` is named `*.test.js`, so jest cannot suite does not fit; nothing in `tests/e2e/` is named `*.test.js`, so jest cannot
pick it up either. Run them locally before changing anything under pick it up either. Neither is wired into the Gitea workflow yet —
`src/popup/views/`. docker-in-docker in CI is a separate question. Run them locally before changing
anything under `src/popup/views/`.
### In CI
`.gitea/workflows/e2e.yml` runs both suites on every push, as two jobs —
`e2e-chrome` and `e2e-firefox` — separate from the `check` workflow, so the
20-second `make test` cap and the local fast path are untouched. Each job is a
checkout and the matching `script/` entrypoint, nothing else.
Docker is the only thing either job needs from the runner, and that is not an
accident. The runner executes a job inside a container against the **host's**
docker daemon, so a `docker run -v "$PWD:/work"` source path is resolved by the
host and mounts an empty directory, and the runner image's node is too old to
install this repo's dependencies. Both suites therefore ship the repo to the
daemon as a build context and build the extension inside the image, which works
identically on a laptop.
The jobs **report, they do not gate.** A failure is a red mark against the
commit that a reviewer has to account for, not a hard block: whether a check
blocks a merge is Gitea branch protection, which this repo does not configure.
That is not only a statement about configuration. The Chrome suite is
**measurably flaky under load** — two of six runs of unmutated code on a busy
machine lost the approval popup out from under the dApp signing wait, always in
the `#183` section, tracked as
[#287](https://git.eeqj.de/sneak/AutistMask/issues/287). So a red `e2e-chrome`
has to be read before it is believed, and that flake is the blocker to ever
making this a required check. Do not answer it with a retry wrapper: a suite
that reruns until it is green stops being evidence.
Nothing in either job can pass vacuously. There is no `continue-on-error` and no
`|| true`; both scripts exit non-zero when docker is missing, when the image
build fails, and when the browser fails to start; the Chrome harness aborts the
suite outright if its network interception is not in effect.
Measured on this repo's runner: `e2e-chrome` about 1m55s cold, almost all of it
the one-time pull of the pinned ~800MB Playwright layer, and well under a minute
once that layer is cached. `e2e-firefox` about 1m05s cold, and it caches its
Firefox and geckodriver downloads the same way.
### Element id guard (part of `make check`) ### Element id guard (part of `make check`)
@@ -614,27 +556,6 @@ Both are click-copyable. Truncating to 4 decimals in summary views is acceptable
for scannability, but the detail view must never discard precision — it is the for scannability, but the detail view must never discard precision — it is the
one place the user can always use to verify exact details. one place the user can always use to verify exact details.
#### Partial USD totals
Prices are fetched for the top 25 tokens only, so an address can hold assets the
extension has no price for. Worth zero and worth an unknown amount are different
facts and are never collapsed into one number. `getAddressValue()` in
`src/shared/prices.js` returns `{ usd, partial }` — the value of the priced
holdings, and whether an unpriced holding was left out of it — and every screen
renders it through `formatAddressTotal()`, so the wording cannot drift:
- Nothing knowable (testnet, or before the first price fetch): no total line.
- Everything priced: `Total: $5,500.00`.
- Part priced: `Total: $3,000.00 plus unpriced tokens` — the figure is real as
far as it goes and is kept, named as a floor rather than the total.
- Nothing priced but something held: `Total: unpriced tokens only`. No figure,
because the only figure available would be the `$0.00` sum of an empty set,
and on the address-removal confirmation that sits directly under "This address
holds a balance."
The per-token balance lines are unaffected: each shows its quantity, and a USD
column that is blank for a token with no price.
#### Language & Labeling #### Language & Labeling
All user-facing text avoids unnecessary jargon wherever possible: All user-facing text avoids unnecessary jargon wherever possible:
@@ -770,9 +691,7 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
- **When**: At least one wallet exists. This is the root screen. - **When**: At least one wallet exists. This is the root screen.
- **Elements**: - **Elements**:
- Active address ETH balance (large) + USD value in parentheses - Active address ETH balance (large) + USD value in parentheses
- "Total:" USD value across ETH and every token shown for the active - "Total:" USD value across ETH and every token shown for the active address
address, written by `formatAddressTotal()` — see
[Partial USD totals](#partial-usd-totals)
- Active address (color dot, full address, etherscan link, tap to copy) - Active address (color dot, full address, etherscan link, tap to copy)
- Send / Receive quick-action buttons, both acting on the active address - Send / Receive quick-action buttons, both acting on the active address
- ETH/USD price display - ETH/USD price display
@@ -834,7 +753,7 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
- Title: "Wallet Name — Address N" - Title: "Wallet Name — Address N"
- ENS name (if resolved, bold above the address) - ENS name (if resolved, bold above the address)
- Full address (color dot, etherscan link, tap to copy) - Full address (color dot, etherscan link, tap to copy)
- USD total for address (see [Partial USD totals](#partial-usd-totals)) - USD total for address
- Balance list: ETH + the ERC-20 tokens shown for this address (4 decimal - Balance list: ETH + the ERC-20 tokens shown for this address (4 decimal
places, USD inline). Each balance row is clickable → **AddressToken** places, USD inline). Each balance row is clickable → **AddressToken**
- Send / Receive / + Token buttons and a "···" menu button - Send / Receive / + Token buttons and a "···" menu button
@@ -1200,13 +1119,11 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
xprv wallet has no recovery phrase to re-import. xprv wallet has no recovery phrase to re-import.
- A warning when the address holds anything, ETH or any tracked ERC-20, - A warning when the address holds anything, ETH or any tracked ERC-20,
followed by the holdings themselves via `balanceLinesForAddress()` and the followed by the holdings themselves via `balanceLinesForAddress()` and the
USD total via `formatAddressTotal()` (see USD total via `getAddressValueUsd()`. The sentence names no figure of its
[Partial USD totals](#partial-usd-totals)). The sentence names no figure own: the lines round to four decimals, so a sentence built from a rounded
of its own: the lines round to four decimals, so a sentence built from a number would report `0.0000 ETH` for an address holding real money. The
rounded number would report `0.0000 ETH` for an address holding real predicate is `addressHoldsFunds()` in `src/popup/views/helpers.js`,
money. The predicate is `addressHoldsFunds()` in unrounded and token-aware. A balance is a warning, never a refusal.
`src/popup/views/helpers.js`, unrounded and token-aware. A balance is a
warning, never a refusal.
- The rule that a wallet always keeps at least one address, and that - The rule that a wallet always keeps at least one address, and that
removing the last one means deleting the wallet from Settings removing the last one means deleting the wallet from Settings
- Error line - Error line
@@ -1276,11 +1193,7 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
opening the window, so the screen shows a complete transaction and the signed opening the window, so the screen shows a complete transaction and the signed
artifact can be compared with it field for field. A request that cannot be artifact can be compared with it field for field. A request that cannot be
populated — unreachable node, reverting gas estimate — opens no window and is populated — unreachable node, reverting gas estimate — opens no window and is
failed back to the site. Only one transaction approval exists at a time: failed back to the site.
populating fixes the nonce, so a second `eth_sendTransaction` arriving while
one is unanswered is refused with EIP-1193 code `-32002` rather than being
populated at the same nonce. It opens no window and takes no nonce, and the
site can send it again once the pending one is answered.
- **Elements**: - **Elements**:
- "Transaction Request" heading - "Transaction Request" heading
- Phishing warning banner (shown when the hostname is on the phishing - Phishing warning banner (shown when the hostname is on the phishing

107
TODO.md
View File

@@ -33,8 +33,7 @@ The backlog lives on the
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) now sit alongside `make check`, which
cannot see a runtime `ReferenceError` in a popup view, and cannot see a runtime `ReferenceError` in a popup view.
`.gitea/workflows/e2e.yml` runs both of them on every push.
# Next Step # Next Step
@@ -49,47 +48,24 @@ undefined identifiers, which is how
- 2026-08-17: The Settings screen is driven in a browser, and every element id - 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 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 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 in the codebase was unverified at runtime. Six new cases in `tests/e2e/run.js`
`tests/e2e/run.js` reach Settings, assert the About well and the wallet list reach Settings, assert the About well and the wallet list were actually
were actually written, assert the four Token Spam Protection checkboxes are written, assert the four Token Spam Protection checkboxes are real checkboxes
real checkboxes defaulted on, and assert the theme and network selectors offer defaulted on, assert the theme and network selectors offer the choices
the choices `src/shared/networks.js` and `index.html` define. The selectors `src/shared/networks.js` defines and carry the persisted value, and toggle one
are then driven to `dark` and `sepolia` — neither is the first `<option>`, so filter off and back on across a popup reopen each way — which runs the change
neither can be read back from the markup with no JavaScript involved — and handler, `saveState()`, `loadState()` and the `init()` assignment rather than
reasserted after a popup reopen before being restored the same way, and one just looking at the screen. Each group records a coverage key and a final case
spam filter is toggled off and back on across a reopen each way. Those round demands the exact set, so a shortened or skipped section reddens the run
trips run the change handler, `saveState()`, `loadState()` and the instead of shrinking it. `tests/popupElementIds.test.js` is the general half
`init()`/`show()` assignments rather than just looking at the screen. `show()` and runs in `make check` with no browser: every literal id reached through
no longer guards its `settings-network` lookup with `if (networkSelect)`: a `$()`, `document.getElementById()`, `showError()`/`hideError()` and
missing element must fail loudly, which is the whole failure mode this unit `showView()` must exist in `src/popup/index.html`, which no id in `index.html`
exists to catch. Each group records a coverage key and a final case demands may define twice. Demonstrated on three deliberate breaks — a typo'd id (both
the exact set, so a shortened or skipped section reddens the run instead of halves red), a handler bound to the wrong but existing element (only the
shrinking it. `tests/popupElementIds.test.js` is the general half and runs in functional e2e case red), and a typo in a view no browser suite opens (only
`make check` with no browser: every literal id reached through `$()`, the static guard red)
`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)). ([#229](https://git.eeqj.de/sneak/AutistMask/issues/229)).
- 2026-08-17: An address total no longer reports `$0.00` for holdings it cannot
price. Prices exist for the top 25 tokens only, so the priced-only sum was
printed as the total and an address holding nothing but unpriced ERC-20s was
shown as worth nothing — directly under "This address holds a balance." on the
address-removal confirmation. `getAddressValue()` in `src/shared/prices.js`
now returns `{ usd, partial }`, keeping worth-zero and worth-an-unknown-amount
apart the way an absent `holders_count` is kept apart from a count of zero,
and every screen renders it through the one `formatAddressTotal()`: the figure
when it covers everything, the figure marked `plus unpriced tokens` when it
covers part, and `Total: unpriced tokens only` when it would cover nothing.
Home, AddressDetail and the removal confirmation all read it, and
`getWalletValue()`/`getTotalValue()` carry `partial` up. Covered by
`tests/addressValue.test.js` — the only-unpriced, genuinely-zero and
fully-priced cases at the helper and at both call sites that return their
markup — demonstrated failing first
([#261](https://git.eeqj.de/sneak/AutistMask/issues/261)).
- 2026-08-17: `README.md` no longer advertises a defect the wallet does not - 2026-08-17: `README.md` no longer advertises a defect the wallet does not
have. The End-to-End Tests section listed the EIP-1193 code being dropped in have. The End-to-End Tests section listed the EIP-1193 code being dropped in
the last hop into the page as a standing limit of the dApp coverage; that the last hop into the page as a standing limit of the dApp coverage; that
@@ -99,31 +75,6 @@ undefined identifiers, which is how
stubbed RPC and the unobservable toolbar popup — were checked against the stubbed RPC and the unobservable toolbar popup — were checked against the
current `src/content/inpage.js` and `tests/e2e/` and left as they are current `src/content/inpage.js` and `tests/e2e/` and left as they are
([#285](https://git.eeqj.de/sneak/AutistMask/issues/285)). ([#285](https://git.eeqj.de/sneak/AutistMask/issues/285)).
- 2026-08-17: One transaction approval at a time. Populating in the background
before the window opens is what makes the displayed object the verified
object, and it also fixes the nonce: two `eth_sendTransaction` calls populated
concurrently took the same nonce from a node that had seen neither broadcast,
and the second could then never be sent, because the only way to give it a
fresh nonce is to populate it again after the user has read the old one off
the screen. A second request is now refused with EIP-1193 `-32002` while one
is unanswered — the slot is taken immediately before population, after the
authorization checks, so no second nonce is allocated, no second window opens,
and a page the wallet refuses anyway cannot hold the slot against the
connected site. The slot is freed at `settleApproval()`, the single point an
approval is retired, so every path that ends an approval ends the hold with
it; an approval whose window is gone and whose attempt has failed is settled
there rather than left waiting on a window that no longer exists, and an
approval window that could not be opened at all is answered with `-32603`
instead of holding the page's promise open. Signature approvals are not gated,
consuming no nonce. A collision that does happen is also reported accurately
now: a broadcast the node refused for the nonce, and an approval carrying a
nonce this worker has already broadcast for that address on that chain (caught
before the node is asked at all), both say the transaction did not reach the
network and to send it again, instead of warning that it may have sent. The
record is keyed by chain as well as address, because nonce spaces are per
chain and low nonces overlap across them. `already known` deliberately keeps
the ambiguous wording, because a node that says it has the transaction has it
([#271](https://git.eeqj.de/sneak/AutistMask/issues/271)).
- 2026-08-14: The parts of the - 2026-08-14: The parts of the
[#150](https://git.eeqj.de/sneak/AutistMask/issues/150) and [#150](https://git.eeqj.de/sneak/AutistMask/issues/150) and
[#151](https://git.eeqj.de/sneak/AutistMask/issues/151) definition of done the [#151](https://git.eeqj.de/sneak/AutistMask/issues/151) definition of done the
@@ -140,24 +91,6 @@ 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: CI runs the browser end-to-end suites. `.gitea/workflows/e2e.yml`
runs `script/test-e2e` and `script/test-e2e-firefox` as two jobs on every
push, separate from `check`, so `make check` and its 20-second `make test` cap
are untouched. Every browser-level guarantee in this repo — the WASM-under-CSP
check, the recovery-phrase and private-key DOM wipes, the ConfirmTx spend
gate, the dApp approval round trips — was enforced only when a human
remembered to run it by hand. The suites could not run on the runner as they
stood: the runner executes a job in a container against the host's docker
daemon, so `docker run -v "$PWD:/work"` mounts an empty directory (measured),
and the runner image's node cannot install this repo's dependencies. Both
suites now ship the repo to the daemon as a build context and build the
extension inside the pinned image, so docker is the only prerequisite on a
runner or a laptop, and both run the image by ID rather than by tag so
concurrent clones cannot swap it. The jobs report rather than gate — this repo
configures no branch protection, and the Chrome suite is measurably flaky
under load, filed as [#287](https://git.eeqj.de/sneak/AutistMask/issues/287)
rather than papered over
([#259](https://git.eeqj.de/sneak/AutistMask/issues/259)).
- 2026-08-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
@@ -481,5 +414,9 @@ tracker.
- Pre-1.0 security review of the extension (key handling, DEBUG mode policy, RPC - Pre-1.0 security review of the extension (key handling, DEBUG mode policy, RPC
input validation) before any 1.0rc tag. Individual filed issues are parts of input validation) before any 1.0rc tag. Individual filed issues are parts of
it, but the review is broader than any of them. it, but the review is broader than any of them.
- Decide whether docker-in-docker makes `make test-e2e` and
`make test-e2e-firefox` runnable in the Gitea workflow. Extending the Chrome
suite itself is tracked as
[#183](https://git.eeqj.de/sneak/AutistMask/issues/183).
- Cut 1.0.0 once the milestone is empty, then continue tagging as milestones - Cut 1.0.0 once the milestone is empty, then continue tagging as milestones
land. land.

View File

@@ -7,29 +7,17 @@
# 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; it is the only check that can see
# a used-but-not-imported identifier blow up at runtime. # a used-but-not-imported identifier blow up at runtime.
# .gitea/workflows/e2e.yml also runs it on every push, in a job separate
# from check so that cap and the local fast path both stay intact.
#
# Docker is the only prerequisite. The repo reaches the container as a
# build context and the extension is built inside it (see
# tests/e2e/Dockerfile), so nothing here depends on the node, yarn or make
# on the machine that starts the run. That is not a convenience: a bind
# mount cannot work under Gitea Actions, and the runner image's node is too
# old to install this repo's dependencies.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
IMAGE="$("$SCRIPT_DIR/projectname")-e2e-chrome" # mcr.microsoft.com/playwright:v1.56.0-noble, 2026-08-09
#
IIDFILE="" # The playwright-core devDependency is pinned to the matching Playwright
# version (1.56.0) and the two must be bumped together: the browsers ship
cleanup() { # inside this image, and playwright-core looks for the exact browser
if [ -n "$IIDFILE" ]; then # revision its own version expects. A mismatch fails at launch.
rm -f "$IIDFILE" IMAGE="mcr.microsoft.com/playwright@sha256:35246d87a7c88ea9b771c65d33171b2611b02a8253b4b12ce6f94376c55f99f2"
fi
}
main() { main() {
cd "$ROOT" cd "$ROOT"
@@ -39,23 +27,14 @@ main() {
exit 1 exit 1
fi fi
IIDFILE="$(mktemp)" echo "Building extension for e2e..."
trap cleanup EXIT yarn run build 2>&1
trap 'cleanup; exit 130' INT TERM
echo "Building the Chrome e2e image (extension included)..."
docker build --iidfile "$IIDFILE" -t "$IMAGE" -f tests/e2e/Dockerfile .
echo "Running e2e suite in the pinned Playwright container..." echo "Running e2e suite in the pinned Playwright container..."
# The image is run by ID, not by tag: where two clones of this repo run
# the suite at once, the other build can move the tag between this
# build and this run, and the suite would then silently test the other
# checkout.
#
# --ipc=host: Chromium's shared-memory needs more than the default # --ipc=host: Chromium's shared-memory needs more than the default
# 64MB /dev/shm or renderers crash. # 64MB /dev/shm or renderers crash.
# HOME=/tmp: the image's root home is not a reliable place for the # --user: keep files the suite touches owned by the caller, not root.
# browser profile. # HOME=/tmp: the mapped uid has no home directory in the image.
# 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 — including the phishing
@@ -72,10 +51,13 @@ main() {
# on a deliberate bump. # on a deliberate bump.
docker run --rm \ docker run --rm \
--ipc=host \ --ipc=host \
--user "$(id -u):$(id -g)" \
-e HOME=/tmp \ -e HOME=/tmp \
-e PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1 \ -e PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1 \
-e "E2E_TRACE_NETWORK=${E2E_TRACE_NETWORK:-0}" \ -e "E2E_TRACE_NETWORK=${E2E_TRACE_NETWORK:-0}" \
"$(cat "$IIDFILE")" \ -v "$ROOT:/work" \
-w /work \
"$IMAGE" \
node tests/e2e/run.js node tests/e2e/run.js
} }

View File

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

View File

@@ -24,7 +24,6 @@ const {
TX_STAGE_VERIFY, TX_STAGE_VERIFY,
TX_STAGE_BROADCAST, TX_STAGE_BROADCAST,
TX_STAGE_INFLIGHT, TX_STAGE_INFLIGHT,
TX_STAGE_NONCE,
} = require("../shared/approvalVerify"); } = require("../shared/approvalVerify");
const { prepareApprovalTx } = require("../shared/approvalTx"); const { prepareApprovalTx } = require("../shared/approvalTx");
const { const {
@@ -58,114 +57,6 @@ const connectedSites = {};
// Pending approval requests: { id: { origin, hostname, resolve } } // Pending approval requests: { id: { origin, hostname, resolve } }
const pendingApprovals = {}; const pendingApprovals = {};
// One transaction approval at a time, wallet-wide.
//
// The transaction a site asks for is populated before its approval window
// opens, so that the object the user is shown is the object the signed
// artifact is verified against. Populating fixes the nonce. Two requests
// populated concurrently therefore take the SAME nonce — the node reports the
// same pending count to both, neither having been broadcast — and whichever is
// broadcast second is refused by the network for a nonce it can never be
// re-signed at, because re-signing it would mean signing something other than
// what was displayed.
//
// So the second request is refused while the first is unanswered. It is
// refused before anything is populated, so no second nonce is allocated at
// all, and while the page is still waiting with nothing on screen. The
// alternatives were considered and rejected in
// https://git.eeqj.de/sneak/AutistMask/issues/271: populating again at Confirm
// puts a nonce on screen that is not the nonce that gets signed, and
// allocating around in-flight approvals makes the wallet's own bookkeeping the
// authority on a nonce the network has not accepted, which an abandoned
// approval then leaves a hole in.
//
// Sign approvals are not gated: a signature consumes no nonce.
//
// The slot is null when free, and otherwise the handle of the request holding
// it. Once that request has raised its approval the handle carries the
// approval's id, so that retiring the approval frees the slot: every exit from
// pendingApprovals goes through settleApproval(), which makes that one hook
// complete. The holder's own finally is the backstop for the interval before
// the approval exists.
let txApprovalSlot = null;
// EIP-1474 "resource unavailable": the standard code for a request that is
// refused because another one is already pending.
const TX_APPROVAL_PENDING_CODE = -32002;
// True at every moment this can be sent: the slot is taken immediately before
// the transaction is populated, so the other request is either being prepared
// or on screen. It does not claim the other one is displayed yet, because for
// the length of one network round trip it is not.
const TX_APPROVAL_PENDING_MESSAGE =
"AutistMask handles one transaction at a time, and another one is" +
" already in progress, so this one was not sent. Please finish that" +
" transaction, then send this one again.";
// Take the slot, or refuse. Nothing awaits between the test and the set, so
// two requests that reach this in the same tick cannot both pass it — the
// position of the call in the handler is irrelevant to that, which is why it
// sits after the authorization checks. A page the wallet is going to refuse
// anyway must not be able to take the slot away from the connected site.
function reserveTxApprovalSlot() {
if (txApprovalSlot) return null;
txApprovalSlot = { approvalId: null };
return txApprovalSlot;
}
// Free the slot, if this handle is still the one holding it.
function releaseTxApprovalSlot(handle) {
if (handle && txApprovalSlot !== handle) return;
txApprovalSlot = null;
}
// Free the slot held on behalf of a retired approval. Called from
// settleApproval() for every approval, and a no-op for the ones the slot was
// not taken for.
function releaseTxApprovalSlotFor(approvalId) {
if (txApprovalSlot && txApprovalSlot.approvalId === approvalId) {
txApprovalSlot = null;
}
}
// Nonces this worker has already handed to the node, per chain and address.
// This is the wallet's own knowledge that a nonce is spent, and it is checked
// before a broadcast rather than after: a node's pending count can lag a
// transaction it has itself just accepted, and a request populated inside that
// window would otherwise be signed and sent at a nonce this wallet has already
// used.
//
// The chain is part of the key because nonce spaces are per chain and the
// wallet switches networks. Without it a nonce spent on one chain would refuse
// that nonce on every other chain — and low nonces overlap across chains as a
// matter of course, so the refusal would be both routine and false.
//
// The record dies with the worker, which is correct rather than merely
// convenient: after a restart the node's count is the only answer available,
// and a transaction of this wallet's that the node has forgotten is one the
// user does want to be able to send again.
const broadcastNonces = {};
function broadcastNoncesFor(chainId, address) {
const key =
String(chainId).toLowerCase() +
":" +
String(address || "").toLowerCase();
if (!broadcastNonces[key]) broadcastNonces[key] = new Set();
return broadcastNonces[key];
}
// An approved transaction's nonce as a decimal string, or null if it cannot be
// read as a number. Verification refuses an unreadable nonce before this is
// ever reached; null here only keeps the record from holding junk.
function approvedNonce(approvedTx) {
try {
return BigInt(approvedTx.nonce).toString();
} catch {
return null;
}
}
async function getState() { async function getState() {
const result = await storageApi.get("autistmask"); const result = await storageApi.get("autistmask");
return ( return (
@@ -257,41 +148,11 @@ function settleApproval(id, result, options) {
const holdsClaim = !!(options && options.holdsClaim); const holdsClaim = !!(options && options.holdsClaim);
if (approval.attemptInFlight && !holdsClaim) return false; if (approval.attemptInFlight && !holdsClaim) return false;
delete pendingApprovals[id]; delete pendingApprovals[id];
// The transaction-approval slot is held for exactly as long as the
// approval it was taken for is alive, and this is the one place an
// approval stops being alive.
releaseTxApprovalSlotFor(id);
approval.resolve(result); approval.resolve(result);
resetPopupUrl(); resetPopupUrl();
return true; return true;
} }
// What a pending approval resolves to when it is given up on rather than
// answered: the window was closed, or could not be opened at all. A tx or sign
// approval answers the requesting page in EIP-1193 shape; a site-connection
// approval answers the connection handler in its own.
function abandonedResult(approval, code, message) {
if (approval.type === "tx" || approval.type === "sign") {
return { error: { code, message } };
}
return { approved: false, remember: false };
}
// A window the user closed without answering is a refusal by the user, which
// is 4001 and the wording every other rejection path already uses.
const APPROVAL_REJECTED_CODE = 4001;
const APPROVAL_REJECTED_MESSAGE = "User rejected the request.";
// The window could not be opened, so the user was never asked. This is the
// wallet failing, not the user refusing, so it does not claim to be a
// rejection: -32603 is the JSON-RPC code for the wallet's own internal
// failure, and the page is told plainly that nothing was shown.
const APPROVAL_WINDOW_FAILED_CODE = -32603;
const APPROVAL_WINDOW_FAILED_MESSAGE =
"AutistMask could not open its approval window, so this request was not" +
" shown to you and nothing was sent.";
// Take exclusive hold of a pending approval for one attempt, or refuse. // Take exclusive hold of a pending approval for one attempt, or refuse.
// //
// An approval that failed retryably has to stay in pendingApprovals, so its // An approval that failed retryably has to stay in pendingApprovals, so its
@@ -311,26 +172,8 @@ function claimApproval(approval) {
// Release an approval whose attempt failed in a way the user can retry. // Release an approval whose attempt failed in a way the user can retry.
// Nothing was broadcast, so the next attempt may claim it. // Nothing was broadcast, so the next attempt may claim it.
//
// Unless the window it would be retried in is already gone. The user closed it
// while the attempt was running and settleApproval() declined then, correctly,
// because the attempt still owned the approval; the attempt has now failed, so
// nothing owns it and nothing can reach it. Left standing it would hold the
// requesting page's promise open forever and, with it, the transaction
// approval slot. It is settled here as the rejection the closed window
// already meant.
function releaseApproval(approval) { function releaseApproval(approval) {
approval.attemptInFlight = false; approval.attemptInFlight = false;
if (approval.windowClosed) {
settleApproval(
approval.id,
abandonedResult(
approval,
APPROVAL_REJECTED_CODE,
APPROVAL_REJECTED_MESSAGE,
),
);
}
} }
// Open approval in a separate popup window. // Open approval in a separate popup window.
@@ -357,36 +200,10 @@ function openApprovalWindow(id) {
); );
} }
windowsApi.create(opts, (win) => { windowsApi.create(opts, (win) => {
const approval = pendingApprovals[id];
if (!approval) {
// Settled while the window was opening — an address switch,
// say. Nothing is waiting on it, and a window showing an
// approval that no longer exists is not left on screen.
if (win) { if (win) {
windowsApi.remove(win.id, () => { pendingApprovals[id].windowId = win.id;
if (runtime.lastError) {
// window already closed
} }
}); });
}
return;
}
if (!win) {
// No window means no way to ever answer this approval, and an
// approval nothing can answer holds the requesting page's
// promise open forever. Settle it now instead.
settleApproval(
id,
abandonedResult(
approval,
APPROVAL_WINDOW_FAILED_CODE,
APPROVAL_WINDOW_FAILED_MESSAGE,
),
);
return;
}
approval.windowId = win.id;
});
}); });
} }
@@ -395,7 +212,7 @@ function openApprovalWindow(id) {
function requestApproval(origin, hostname) { function requestApproval(origin, hostname) {
return new Promise((resolve) => { return new Promise((resolve) => {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
pendingApprovals[id] = { id, origin, hostname, resolve }; pendingApprovals[id] = { origin, hostname, resolve };
if (actionApi && typeof actionApi.openPopup === "function") { if (actionApi && typeof actionApi.openPopup === "function") {
actionApi.setPopup({ actionApi.setPopup({
@@ -426,13 +243,10 @@ function requestApproval(origin, hostname) {
// it is pinned here rather than read again at signing time — an address switch // it is pinned here rather than read again at signing time — an address switch
// between approval and signing must refuse, not sign from an account this // between approval and signing must refuse, not sign from an account this
// screen never named. // screen never named.
// `slot` is the transaction-approval slot its caller holds. Handing the function requestTxApproval(origin, hostname, approvedTx, approvedFrom) {
// approval's id to it is what makes retiring the approval free the slot.
function requestTxApproval(origin, hostname, approvedTx, approvedFrom, slot) {
return new Promise((resolve) => { return new Promise((resolve) => {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
pendingApprovals[id] = { pendingApprovals[id] = {
id,
origin, origin,
hostname, hostname,
approvedTx, approvedTx,
@@ -440,7 +254,6 @@ function requestTxApproval(origin, hostname, approvedTx, approvedFrom, slot) {
resolve, resolve,
type: "tx", type: "tx",
}; };
if (slot) slot.approvalId = id;
openApprovalWindow(id); openApprovalWindow(id);
}); });
@@ -454,7 +267,6 @@ function requestSignApproval(origin, hostname, signParams, approvedFrom) {
return new Promise((resolve) => { return new Promise((resolve) => {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
pendingApprovals[id] = { pendingApprovals[id] = {
id,
origin, origin,
hostname, hostname,
signParams, signParams,
@@ -773,30 +585,10 @@ async function handleRpc(method, params, origin) {
} }
if (method === "eth_sendTransaction") { if (method === "eth_sendTransaction") {
return await handleSendTransaction(params, origin);
}
// Proxy safe read-only methods to the RPC node
if (PROXY_METHODS.includes(method)) {
try {
const result = await proxyRpc(method, params);
return { result };
} catch (e) {
return { error: { message: e.message } };
}
}
return { error: { message: "Unsupported method: " + method } };
}
// The body of eth_sendTransaction, from the connection check through to the
// user's decision. It takes the single transaction-approval slot once it knows
// it is going to populate a transaction, and holds it until the requesting
// page has its answer.
async function handleSendTransaction(params, origin) {
const s = await getState(); const s = await getState();
const activeAddress = await getActiveAddress(); const activeAddress = await getActiveAddress();
if (!activeAddress) return { error: { message: "No accounts available" } }; if (!activeAddress)
return { error: { message: "No accounts available" } };
const hostname = extractHostname(origin); const hostname = extractHostname(origin);
const allowed = s.allowedSites[activeAddress] || []; const allowed = s.allowedSites[activeAddress] || [];
@@ -818,23 +610,6 @@ async function handleSendTransaction(params, origin) {
}; };
} }
// Everything above refuses without populating anything, so the slot is
// taken here rather than at the top of the handler: a page the wallet was
// never going to serve must not be able to hold the slot and make the
// connected site's own transaction fail as "already in progress". The
// reservation is atomic because nothing awaits between its test and its
// set, not because of where it sits.
const slot = reserveTxApprovalSlot();
if (!slot) {
return {
error: {
code: TX_APPROVAL_PENDING_CODE,
message: TX_APPROVAL_PENDING_MESSAGE,
},
};
}
try {
// Populate here, before any window opens, so that the transaction the // Populate here, before any window opens, so that the transaction the
// user is shown is a complete one and is the same object the signed // user is shown is a complete one and is the same object the signed
// artifact is checked against. A failure raises no approval at all and // artifact is checked against. A failure raises no approval at all and
@@ -869,17 +644,22 @@ async function handleSendTransaction(params, origin) {
hostname, hostname,
approvedTx, approvedTx,
activeAddress, activeAddress,
slot,
); );
if (decision.error) return { error: decision.error }; if (decision.error) return { error: decision.error };
return { result: decision.txHash }; return { result: decision.txHash };
} finally {
// Retiring the approval has normally freed the slot already, through
// settleApproval(); this covers the paths that return before an
// approval exists at all, and frees nothing if another request has
// since taken the slot.
releaseTxApprovalSlot(slot);
} }
// Proxy safe read-only methods to the RPC node
if (PROXY_METHODS.includes(method)) {
try {
const result = await proxyRpc(method, params);
return { result };
} catch (e) {
return { error: { message: e.message } };
}
}
return { error: { message: "Unsupported method: " + method } };
} }
// Broadcast chainChanged to all tabs when the network is switched. // Broadcast chainChanged to all tabs when the network is switched.
@@ -914,11 +694,15 @@ async function broadcastAccountsChanged() {
// being signed and broadcast right now, and neither rejecting it to the // being signed and broadcast right now, and neither rejecting it to the
// page nor closing the window it is reporting into is survivable. // page nor closing the window it is reporting into is survivable.
for (const [id, approval] of Object.entries(pendingApprovals)) { for (const [id, approval] of Object.entries(pendingApprovals)) {
const rejection = abandonedResult( const rejection =
approval, approval.type === "tx" || approval.type === "sign"
APPROVAL_REJECTED_CODE, ? {
APPROVAL_REJECTED_MESSAGE, error: {
); code: 4001,
message: "User rejected the request.",
},
}
: { approved: false, remember: false };
if (!settleApproval(id, rejection)) continue; if (!settleApproval(id, rejection)) continue;
if (approval.windowId) { if (approval.windowId) {
windowsApi.remove(approval.windowId, () => { windowsApi.remove(approval.windowId, () => {
@@ -1047,19 +831,21 @@ startBackgroundJobs();
// verify and broadcast it is waiting on, so a user closing an apparently-hung // verify and broadcast it is waiting on, so a user closing an apparently-hung
// window is an ordinary event with an attempt already in flight behind it. // window is an ordinary event with an attempt already in flight behind it.
// settleApproval() refuses those, which leaves the attempt to report its real // settleApproval() refuses those, which leaves the attempt to report its real
// outcome to the page — and the window is recorded as gone, so that an attempt // outcome to the page.
// which then fails retryably settles instead of waiting in a window that no
// longer exists.
if (windowsApi && windowsApi.onRemoved) { if (windowsApi && windowsApi.onRemoved) {
windowsApi.onRemoved.addListener((windowId) => { windowsApi.onRemoved.addListener((windowId) => {
for (const [id, approval] of Object.entries(pendingApprovals)) { for (const [id, approval] of Object.entries(pendingApprovals)) {
if (approval.windowId !== windowId) continue; if (approval.windowId !== windowId) continue;
const rejection = abandonedResult( const rejection =
approval, approval.type === "tx" || approval.type === "sign"
APPROVAL_REJECTED_CODE, ? {
APPROVAL_REJECTED_MESSAGE, error: {
); code: 4001,
if (!settleApproval(id, rejection)) approval.windowClosed = true; message: "User rejected the request.",
},
}
: { approved: false, remember: false };
settleApproval(id, rejection);
} }
}); });
} }
@@ -1173,7 +959,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
sendResponse({ sendResponse({
error: outcome.error, error: outcome.error,
retryable: outcome.retryable, retryable: outcome.retryable,
stage: outcome.stage, stage: TX_STAGE_SIGN,
}); });
return false; return false;
} }
@@ -1189,15 +975,8 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
} }
(async () => { (async () => {
// The chain this attempt is on, read once. Verification below
// refuses an artifact signed for any other chain, and the nonce
// record is both consulted and written under this one, so a
// network switch part-way through cannot make the check and the
// record disagree about which chain the nonce was spent on.
let chainId;
try { try {
await loadState(); await loadState();
chainId = currentNetwork().chainId;
const activeAddress = await getActiveAddress(); const activeAddress = await getActiveAddress();
// An address switch between approval and signing refuses. The // An address switch between approval and signing refuses. The
// approval named one account; signing from whichever account // approval named one account; signing from whichever account
@@ -1220,7 +999,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
msg.rawSignedTx, msg.rawSignedTx,
approval.approvedTx, approval.approvedTx,
approval.approvedFrom, approval.approvedFrom,
chainId, currentNetwork().chainId,
); );
} catch (e) { } catch (e) {
// A signed transaction that is not the approved one is not // A signed transaction that is not the approved one is not
@@ -1240,31 +1019,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
sendResponse({ sendResponse({
error: outcome.error, error: outcome.error,
retryable: outcome.retryable, retryable: outcome.retryable,
stage: outcome.stage, stage: TX_STAGE_VERIFY,
});
return;
}
// A nonce this worker has already broadcast for this address on
// this chain. The node is not asked: it has answered once already,
// and the wallet holding the receipt of that answer is what makes
// this failure one the user can be told did not reach the network.
// A nonce spent on another chain is not spent here — the chains
// count separately, and refusing across them would block ordinary
// use with a message that is not true.
const nonce = approvedNonce(approval.approvedTx);
const spent = broadcastNoncesFor(chainId, approval.approvedFrom);
if (nonce !== null && spent.has(nonce)) {
const outcome = describeTxFailure(TX_STAGE_NONCE, null);
settleApproval(
msg.id,
{ error: { message: outcome.error } },
{ holdsClaim: true },
);
sendResponse({
error: outcome.error,
retryable: outcome.retryable,
stage: outcome.stage,
}); });
return; return;
} }
@@ -1272,7 +1027,6 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
try { try {
const provider = getProvider(state.rpcUrl); const provider = getProvider(state.rpcUrl);
const tx = await provider.broadcastTransaction(msg.rawSignedTx); const tx = await provider.broadcastTransaction(msg.rawSignedTx);
if (nonce !== null) spent.add(nonce);
settleApproval( settleApproval(
msg.id, msg.id,
{ txHash: tx.hash }, { txHash: tx.hash },
@@ -1285,11 +1039,6 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
// tell a transaction that never left from one already in the // tell a transaction that never left from one already in the
// mempool. The page has been given its outcome for this // mempool. The page has been given its outcome for this
// request; a second attempt would report a second one. // request; a second attempt would report a second one.
//
// Unless the node blamed the nonce, which is the one answer
// that says plainly it did not take the transaction:
// describeTxFailure() reclassifies that, and the stage it
// returns is the one reported.
const outcome = describeTxFailure(TX_STAGE_BROADCAST, e); const outcome = describeTxFailure(TX_STAGE_BROADCAST, e);
settleApproval( settleApproval(
msg.id, msg.id,
@@ -1299,7 +1048,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
sendResponse({ sendResponse({
error: outcome.error, error: outcome.error,
retryable: outcome.retryable, retryable: outcome.retryable,
stage: outcome.stage, stage: TX_STAGE_BROADCAST,
}); });
} }
})(); })();

View File

@@ -13,7 +13,7 @@ const {
pushCurrentView, pushCurrentView,
} = require("./helpers"); } = require("./helpers");
const { state, currentAddress, saveState } = require("../../shared/state"); const { state, currentAddress, saveState } = require("../../shared/state");
const { formatAddressTotal, getAddressValue } = require("../../shared/prices"); const { formatUsd, getAddressValueUsd } = require("../../shared/prices");
const { const {
fetchRecentTransactions, fetchRecentTransactions,
filterTransactions, filterTransactions,
@@ -64,7 +64,7 @@ function show() {
}); });
$("address-line").dataset.full = addr.address; $("address-line").dataset.full = addr.address;
attachCopyHandlers($("address-line")); attachCopyHandlers($("address-line"));
const usdTotal = formatAddressTotal(getAddressValue(addr)); const usdTotal = formatUsd(getAddressValueUsd(addr));
$("address-usd-total").innerHTML = usdTotal || "&nbsp;"; $("address-usd-total").innerHTML = usdTotal || "&nbsp;";
const ensEl = $("address-ens"); const ensEl = $("address-ens");
// ENS is now shown inside renderAddressHtml, hide the separate element // ENS is now shown inside renderAddressHtml, hide the separate element

View File

@@ -18,7 +18,11 @@ const {
} = require("./helpers"); } = require("./helpers");
const { state, currentAddress, saveState } = require("../../shared/state"); const { state, currentAddress, 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,
getAddressValueUsd,
} = require("../../shared/prices");
const { const {
fetchRecentTransactions, fetchRecentTransactions,
filterTransactions, filterTransactions,

View File

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

View File

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

View File

@@ -28,9 +28,8 @@ const {
} = require("../../shared/walletDefects"); } = require("../../shared/walletDefects");
const { const {
formatUsd, formatUsd,
formatAddressTotal,
getPrice, getPrice,
getAddressValue, getAddressValueUsd,
} = require("../../shared/prices"); } = require("../../shared/prices");
const { const {
fetchRecentTransactions, fetchRecentTransactions,
@@ -72,7 +71,9 @@ function renderTotalValue() {
el.textContent = ethStr + ethUsd; el.textContent = ethStr + ethUsd;
if (subEl) { if (subEl) {
subEl.innerHTML = formatAddressTotal(getAddressValue(addr)) || "&nbsp;"; const totalUsd = getAddressValueUsd(addr);
subEl.innerHTML =
totalUsd !== null ? "Total: " + formatUsd(totalUsd) : "&nbsp;";
} }
} }
@@ -256,8 +257,8 @@ function walletListHtml() {
html += `<span class="flex items-center break-all">${addr.ensName ? "" : dot}${addr.address}</span>`; html += `<span class="flex items-center break-all">${addr.ensName ? "" : dot}${addr.address}</span>`;
html += `<span class="flex-shrink-0 ml-1">${infoBtn}${removeBtn}</span>`; html += `<span class="flex-shrink-0 ml-1">${infoBtn}${removeBtn}</span>`;
html += `</div>`; html += `</div>`;
const addrTotal = formatAddressTotal(getAddressValue(addr)); const addrUsd = formatUsd(getAddressValueUsd(addr));
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrTotal || "&nbsp;"}</div>`; html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrUsd || "&nbsp;"}</div>`;
html += balanceLinesForAddress( html += balanceLinesForAddress(
addr, addr,
state.trackedTokens, state.trackedTokens,

View File

@@ -169,7 +169,10 @@ 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;
$("settings-network").value = state.networkId; const networkSelect = $("settings-network");
if (networkSelect) {
networkSelect.value = state.networkId;
}
renderTrackedTokens(); renderTrackedTokens();
renderSiteLists(); renderSiteLists();
renderWalletListSettings(); renderWalletListSettings();
@@ -281,6 +284,7 @@ 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);
@@ -288,6 +292,7 @@ function init(ctx) {
$("settings-blockscout").value = state.blockscoutUrl; $("settings-blockscout").value = state.blockscoutUrl;
showFlash("Switched to " + net.name + "."); showFlash("Switched to " + net.name + ".");
}); });
}
$("settings-show-zero-balances").checked = state.showZeroBalanceTokens; $("settings-show-zero-balances").checked = state.showZeroBalanceTokens;
$("settings-show-zero-balances").addEventListener("change", async () => { $("settings-show-zero-balances").addEventListener("change", async () => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,8 +30,6 @@ const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const ORIGIN = "https://dapp.example"; const ORIGIN = "https://dapp.example";
const HOSTNAME = "dapp.example"; const HOSTNAME = "dapp.example";
// A page the wallet has never been connected to, whose requests are refused.
const UNCONNECTED_ORIGIN = "https://stranger.example";
const EXT_URL = "chrome-extension://autistmask/"; const EXT_URL = "chrome-extension://autistmask/";
// 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
@@ -53,16 +51,10 @@ const MESSAGE = "0x48656c6c6f204175746973744d61736b";
// The transaction the background populates and the approval screen displays. // The transaction the background populates and the approval screen displays.
// The nonce is a parameter because the duplicate case turns on two artifacts // The nonce is a parameter because the duplicate case turns on two artifacts
// differing in a field the dApp fixed nothing for. // differing in a field the dApp fixed nothing for.
// The two chains the tests switch between, as both forms the code uses: the function populated(nonce) {
// hex chain id the wallet's network record carries, and the number the node
// and the signed artifact carry.
const MAINNET = { hex: "0x1", num: 1 };
const SEPOLIA = { hex: "0xaa36a7", num: 11155111 };
function populated(nonce, chainId) {
return { return {
type: 2, type: 2,
chainId: chainId || MAINNET.num, chainId: 1,
nonce, nonce,
gasLimit: 100000n, gasLimit: 100000n,
maxFeePerGas: 2000000000n, maxFeePerGas: 2000000000n,
@@ -73,17 +65,17 @@ function populated(nonce, chainId) {
}; };
} }
function signedAtNonce(nonce, withWallet, chainId) { function signedAtNonce(nonce, withWallet) {
return (withWallet || signer).signTransaction(populated(nonce, chainId)); return (withWallet || signer).signTransaction(populated(nonce));
} }
// The node the background populates against. Its answers are the numbers the // The node the background populates against. Its answers are the numbers the
// approval screen shows, so they are also the numbers every artifact below is // approval screen shows, so they are also the numbers every artifact below is
// signed at. // signed at.
function fakeProvider(broadcastTransaction, overrides, chainId) { function fakeProvider(broadcastTransaction, overrides) {
return { return {
broadcastTransaction, broadcastTransaction,
getNetwork: async () => Network.from(chainId || MAINNET.num), getNetwork: async () => Network.from(1),
getTransactionCount: async () => NONCE, getTransactionCount: async () => NONCE,
estimateGas: async () => 100000n, estimateGas: async () => 100000n,
getFeeData: async () => ({ getFeeData: async () => ({
@@ -119,20 +111,14 @@ function loadBackground(options) {
const broadcastTransaction = jest.fn(); const broadcastTransaction = jest.fn();
const loadState = jest.fn(opts.loadState || (async () => {})); const loadState = jest.fn(opts.loadState || (async () => {}));
// The network the wallet is on, which the tests switch under a pending
// approval. The node the transaction is populated against is on the same
// one, as it would be: switching networks switches the RPC endpoint too.
let chain = MAINNET;
jest.doMock("../src/shared/state", () => ({ jest.doMock("../src/shared/state", () => ({
state: { rpcUrl: "https://rpc.invalid", wallets: [] }, state: { rpcUrl: "https://rpc.invalid", wallets: [] },
loadState, loadState,
saveState: jest.fn(async () => {}), saveState: jest.fn(async () => {}),
currentNetwork: () => ({ chainId: chain.hex }), currentNetwork: () => ({ chainId: "0x1" }),
})); }));
jest.doMock("../src/shared/balances", () => ({ jest.doMock("../src/shared/balances", () => ({
getProvider: () => getProvider: () => fakeProvider(broadcastTransaction, opts.provider),
fakeProvider(broadcastTransaction, opts.provider, chain.num),
refreshBalances: jest.fn(async () => {}), refreshBalances: jest.fn(async () => {}),
})); }));
jest.doMock("../src/shared/phishingDomains", () => ({ jest.doMock("../src/shared/phishingDomains", () => ({
@@ -184,9 +170,7 @@ function loadBackground(options) {
getLastFocused: (cb) => cb(null), getLastFocused: (cb) => cb(null),
create: (options2, cb) => { create: (options2, cb) => {
created.push(options2); created.push(options2);
// A browser that answers with no window at all. The approval cb({ id: created.length });
// then has no window it can ever be answered in.
cb(opts.noWindow ? undefined : { id: created.length });
}, },
remove: (id, cb) => { remove: (id, cb) => {
removed.push(id); removed.push(id);
@@ -220,12 +204,8 @@ function loadBackground(options) {
// Raise a pending transaction approval the way a dApp does, and dig the // Raise a pending transaction approval the way a dApp does, and dig the
// approval id back out of the popup URL the background opened. // approval id back out of the popup URL the background opened.
function requestTx(txParams, origin) { function requestTx(txParams) {
let rpcResult = null; let rpcResult = null;
// The window this request opens, if it opens one. A request refused
// before an approval is raised opens none, and the window belonging to
// some other request must not be handed back as this one's.
const windowIndex = created.length;
const sendResponse = jest.fn((r) => { const sendResponse = jest.fn((r) => {
rpcResult = r; rpcResult = r;
}); });
@@ -235,16 +215,11 @@ function loadBackground(options) {
method: "eth_sendTransaction", method: "eth_sendTransaction",
params: [txParams || TX_PARAMS], params: [txParams || TX_PARAMS],
}, },
{ origin: origin || ORIGIN }, { origin: ORIGIN },
sendResponse, sendResponse,
); );
return { return {
id: () => id: () => new URL(created[0].url).searchParams.get("approval"),
created.length > windowIndex
? new URL(created[windowIndex].url).searchParams.get(
"approval",
)
: null,
result: () => rpcResult, result: () => rpcResult,
}; };
} }
@@ -294,10 +269,6 @@ function loadBackground(options) {
setActiveAddress: (address) => { setActiveAddress: (address) => {
persisted.activeAddress = address; persisted.activeAddress = address;
}, },
// The user switching network in the toolbar popup.
setNetwork: (network) => {
chain = network;
},
fromPopup: { url: EXT_URL + "src/popup/index.html" }, fromPopup: { url: EXT_URL + "src/popup/index.html" },
}; };
} }
@@ -473,318 +444,6 @@ describe("one approval, one broadcast", () => {
}); });
}); });
// Populating the transaction before the approval window opens is what makes
// the displayed object the verified object. It also fixes the nonce before the
// user has answered anything: two requests populated concurrently take the
// same nonce from a node that has seen neither of them broadcast, and the
// second can then never be sent, because the only way to give it a fresh nonce
// is to populate it again after the user has read the old one off the screen.
// So the second request is refused while the first is unanswered.
describe("one transaction approval at a time", () => {
test("a second eth_sendTransaction while one is pending is refused before it takes a nonce", async () => {
const getTransactionCount = jest.fn(async () => NONCE);
const bg = loadBackground({ provider: { getTransactionCount } });
const first = bg.requestTx();
await settle();
expect(first.id()).toBeTruthy();
expect(getTransactionCount).toHaveBeenCalledTimes(1);
const second = bg.requestTx();
await settle();
expect(second.result()).toEqual({
error: {
code: -32002,
message: expect.stringMatching(
/one transaction at a time.+already in progress/,
),
},
});
// Where the refusal happened matters as much as that it happened: no
// second window, and the node was never asked for a second nonce.
expect(bg.created).toHaveLength(1);
expect(getTransactionCount).toHaveBeenCalledTimes(1);
// The refusal leaves the pending approval untouched, and it still
// sends.
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id: first.id(),
approved: true,
rawSignedTx: await signedAtNonce(NONCE),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(first.result()).toEqual({ result: "0xfeed" });
});
// The slot is only defensible if the wallet was going to raise an approval
// anyway. Taken any earlier, a request the wallet refuses outright still
// holds it, and any page at all — connected or not — can deny the user's
// own transactions for as long as it keeps asking.
test("a request the wallet refuses does not take the slot from the connected site", async () => {
const bg = loadBackground();
// Both delivered before either reaches its first suspension point,
// which is the interleaving the slot exists for.
const stranger = bg.requestTx(TX_PARAMS, UNCONNECTED_ORIGIN);
const connected = bg.requestTx();
await settle();
expect(stranger.result()).toEqual({
error: { code: 4100, message: "Unauthorized" },
});
// The connected site's transaction was raised, not refused as one the
// user already has in progress.
expect(connected.result()).toBeNull();
expect(connected.id()).toBeTruthy();
expect(bg.created).toHaveLength(1);
});
// The user closes an approval window that looks hung while the attempt
// behind it is still running, and that attempt then fails in a way that
// would normally leave the approval standing for a retry. There is no
// window left to retry in, so leaving it standing answers the requesting
// page never — and holds the slot for the life of the worker with it.
test("an approval whose window closed under a failed attempt is answered, and frees the next request", async () => {
const stalled = deferred();
const bg = loadBackground({
loadState: async () => {
await stalled.promise;
throw new Error("The wallet data could not be read.");
},
});
const first = bg.requestTx();
await settle();
bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id: first.id(),
approved: true,
rawSignedTx: await signedAtNonce(NONCE),
},
{ url: bg.fromPopup.url },
);
await settle();
// The attempt owns the approval, so closing the window does not settle
// it: the attempt may yet broadcast, and it is the one that reports.
bg.closeWindow(1);
await settle();
expect(first.result()).toBeNull();
stalled.resolve();
await settle();
expect(first.result()).toEqual({
error: { code: 4001, message: "User rejected the request." },
});
const second = bg.requestTx();
await settle();
expect(second.result()).toBeNull();
expect(second.id()).toBeTruthy();
expect(bg.created).toHaveLength(2);
});
// An approval with no window is one nothing can ever answer.
test("a request whose approval window cannot be opened is answered rather than left waiting", async () => {
const bg = loadBackground({ noWindow: true });
const first = bg.requestTx();
await settle();
expect(first.result()).toEqual({
error: {
code: -32603,
message: expect.stringMatching(
/could not open its approval window/,
),
},
});
// And it did not take the slot with it.
const second = bg.requestTx();
await settle();
expect(second.result()).toEqual({
error: {
code: -32603,
message: expect.stringMatching(
/could not open its approval window/,
),
},
});
});
test("an answered approval frees the next request", async () => {
const bg = loadBackground();
const first = bg.requestTx();
await settle();
// The user closes the approval window, which rejects it.
bg.closeWindow(1);
await settle();
expect(first.result()).toEqual({
error: { code: 4001, message: "User rejected the request." },
});
const second = bg.requestTx();
await settle();
expect(second.id()).toBeTruthy();
expect(bg.created).toHaveLength(2);
});
test("a signature request is not held up by a pending transaction", async () => {
const bg = loadBackground();
bg.requestTx();
await settle();
// A signature consumes no nonce, so it has nothing to collide with.
const signing = bg.requestSign();
await settle();
expect(signing.id()).toBeTruthy();
expect(signing.result()).toBeNull();
expect(bg.created).toHaveLength(2);
});
});
// A nonce collision found before the transaction reaches the network is the
// one send failure the wallet can speak about with certainty. The user is told
// it did not go out and to send it again, rather than being warned it might
// already be on the chain — which would send them looking for a transaction
// that does not exist, and stop them retrying the one that never went.
describe("a nonce collision is reported as a transaction that did not go out", () => {
test("a broadcast the node refused for the nonce is not reported as possibly sent", async () => {
const bg = loadBackground();
const pending = bg.requestTx();
await settle();
bg.broadcastTransaction.mockRejectedValue(
Object.assign(new Error("nonce too low"), {
code: "NONCE_EXPIRED",
}),
);
const answer = bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id: pending.id(),
approved: true,
rawSignedTx: await signedAtNonce(NONCE),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(answer.sendResponse).toHaveBeenCalledWith({
error: expect.stringMatching(/nonce had already been used/),
retryable: false,
stage: "nonce",
});
expect(pending.result()).toEqual({
error: {
message: expect.stringMatching(
/transaction was not sent, because its nonce/,
),
},
});
});
test("a nonce this wallet already broadcast is refused without asking the node again", async () => {
const bg = loadBackground();
const first = bg.requestTx();
await settle();
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id: first.id(),
approved: true,
rawSignedTx: await signedAtNonce(NONCE),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(first.result()).toEqual({ result: "0xfeed" });
// The stubbed node still reports NONCE as the next nonce — a pending
// count that lags a broadcast the node has already taken — so this
// second approval is populated at a nonce this worker has spent.
const second = bg.requestTx();
await settle();
const answer = bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id: second.id(),
approved: true,
rawSignedTx: await signedAtNonce(NONCE),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
expect(answer.sendResponse).toHaveBeenCalledWith({
error: expect.stringMatching(/nonce had already been used/),
retryable: false,
stage: "nonce",
});
expect(second.result()).toEqual({
error: {
message: expect.stringMatching(/nonce had already been used/),
},
});
});
// Nonce spaces are per chain, and the wallet switches networks. A nonce
// this wallet spent on one chain says nothing about the same nonce on
// another — and low nonces overlap across chains as a matter of course, so
// a record that ignored the chain would refuse ordinary transactions,
// permanently and with a message that is not true of them.
test("a nonce spent on one chain is not refused on another", async () => {
const bg = loadBackground();
const first = bg.requestTx();
await settle();
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id: first.id(),
approved: true,
rawSignedTx: await signedAtNonce(NONCE),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(first.result()).toEqual({ result: "0xfeed" });
// The user switches network. On this chain the address has sent
// nothing, so the node populates the next transaction at the same
// nonce — correctly.
bg.setNetwork(SEPOLIA);
const second = bg.requestTx();
await settle();
bg.broadcastTransaction.mockResolvedValue({ hash: "0xbeef" });
bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id: second.id(),
approved: true,
rawSignedTx: await signedAtNonce(NONCE, undefined, SEPOLIA.num),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(2);
expect(second.result()).toEqual({ result: "0xbeef" });
});
});
// The approval carries the transaction the user was shown and the address it // The approval carries the transaction the user was shown and the address it
// was raised for, and the artifact is checked against both. Every case here is // was raised for, and the artifact is checked against both. Every case here is
// one the old comparison — against the dApp's request, for the address that is // one the old comparison — against the dApp's request, for the address that is

View File

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

View File

@@ -1,34 +0,0 @@
# Chrome end-to-end image: the pinned Playwright image with this repo and a
# freshly built extension inside it, built by script/test-e2e. The suite is
# still started with `docker run`, so every runtime flag the harness needs
# (--ipc=host in particular) applies as before.
#
# The repo is baked in rather than bind-mounted because a bind mount does
# not resolve under Gitea Actions: the runner runs the job in a container
# against the HOST's docker socket, so the source side of a -v is resolved
# by the host daemon while the job's checkout lives on a docker volume that
# is not a host path -- the mount silently succeeds and /work is empty. A
# build context is streamed to the daemon and so works from anywhere.
# Building the extension here too means the machine starting a run needs
# docker and nothing else.
# mcr.microsoft.com/playwright:v1.56.0-noble, 2026-08-09
#
# The playwright-core devDependency is pinned to the matching Playwright
# version (1.56.0) and the two must be bumped together: the browsers ship
# inside this image, and playwright-core looks for the exact browser
# revision its own version expects. A mismatch fails at launch.
FROM mcr.microsoft.com/playwright@sha256:35246d87a7c88ea9b771c65d33171b2611b02a8253b4b12ce6f94376c55f99f2
WORKDIR /work
# Same layering as the root Dockerfile: script/bootstrap installs the
# prerequisites and the dependencies, and the manifests are copied first so
# that layer is cached until they change.
COPY script/ script/
COPY package.json yarn.lock ./
RUN script/bootstrap
COPY . .
RUN make build

View File

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

View File

@@ -978,8 +978,6 @@ const SETTINGS_COVERAGE = [
"spam-checkbox-defaults", "spam-checkbox-defaults",
"theme-select", "theme-select",
"network-select", "network-select",
"selector-round-trip",
"selector-restore",
"toggle-off-survives-reopen", "toggle-off-survives-reopen",
"toggle-on-survives-reopen", "toggle-on-survives-reopen",
"wallet-list", "wallet-list",
@@ -1034,13 +1032,11 @@ test("Settings renders with the whole screen populated (#229)", async (env) => {
await visible(env.page, "#view-main"); await visible(env.page, "#view-main");
await openSettings(env.page); await openSettings(env.page);
// show() writes the About well near its end — only the debug well and // show() writes the About well last thing before showView(), so an id
// the debug-mode checkbox follow it — and showView() is the last thing // it cannot find aborts before Settings is ever displayed. Reading the
// of all, so an id show() cannot find aborts before Settings is ever // values back proves the whole of show() ran, not just enough of it to
// displayed. Reading these values back proves show() ran through to // unhide the section. These are filled from build-time constants that
// there, not just far enough to unhide the section. They are filled // always have a value, so empty means the write did not happen.
// from build-time constants that always have a value, so empty means
// the write did not happen.
const about = await env.page.evaluate(() => { const about = await env.page.evaluate(() => {
const out = {}; const out = {};
for (const id of [ for (const id of [
@@ -1103,6 +1099,12 @@ test("the theme and network selectors render their real choices (#229)", async (
theme.options.join(",") === "system,light,dark", theme.options.join(",") === "system,light,dark",
"the theme selector offers " + JSON.stringify(theme.options), "the theme selector offers " + JSON.stringify(theme.options),
); );
assert(
theme.value === "system",
"the theme selector shows " +
JSON.stringify(theme.value) +
", expected the persisted default 'system'",
);
env.settingsCoverage.add("theme-select"); env.settingsCoverage.add("theme-select");
const network = await controlState(env.page, "settings-network"); const network = await controlState(env.page, "settings-network");
@@ -1119,87 +1121,13 @@ test("the theme and network selectors render their real choices (#229)", async (
", expected the networks in src/shared/networks.js: " + ", expected the networks in src/shared/networks.js: " +
wantNetworks, 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( assert(
got.theme === wantTheme, network.value === "mainnet",
"the theme selector shows " +
JSON.stringify(got.theme) +
" " +
where +
", expected " +
JSON.stringify(wantTheme),
);
assert(
got.network === wantNetwork,
"the network selector shows " + "the network selector shows " +
JSON.stringify(got.network) + JSON.stringify(network.value) +
" " + ", expected the persisted default 'mainnet'",
where +
", expected " +
JSON.stringify(wantNetwork),
); );
} env.settingsCoverage.add("network-select");
// 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 // The functional half. A checkbox that renders but is not wired looks