Compare commits

..

1 Commits

Author SHA1 Message Date
ee9932b01b test: containerized Firefox end-to-end harness (closes #184)
All checks were successful
check / check (push) Successful in 28s
Drives the real popup in a real Firefox with dist/firefox/ installed as an
unpacked MV2 temporary add-on, via geckodriver. Covers popup load, wallet
creation through the UI, and the Add Token screen. Outside make check, like
the Chrome suite.

Zero npm dependencies: tests/e2e/firefox/driver.js is a WebDriver client
over global fetch and child_process against geckodriver's HTTP API. The
Dockerfile pins the node base image, the Firefox 153.0.3 tarball and
geckodriver 0.36.0 by digest.

Errors are read from the privileged nsIConsoleService in Marionette's chrome
context, filtered to non-warning entries whose sourceName is the extension
origin. BiDi log.entryAdded delivers nothing at all for extension pages, so
a Playwright-BiDi or Puppeteer-BiDi harness would see nothing and report
success; the code says so where someone would be tempted to simplify it.
Errors logged during add-on install and background startup are drained and
folded into step 1, never discarded: a throw at the top of
src/background/index.js kills the background page and fails the run.
Content-script capture is left as unverified, because --network none leaves
no http:// page for a content script to be injected into.

No driver layer is shared with the Chrome suite and the three UI steps are
written twice deliberately: the two backends have no common substrate, and
three steps do not pay for a shim.

Two limits are documented rather than papered over. Error capture is
poll-based, so an error is attributed to a step and not to a moment within
it, and an error logged more than ~1s after the last step is missed
entirely because the browser is torn down. Nothing is stubbed; the container
runs with --network none instead, which proves no request escaped, cannot
report which were attempted, and runs only the failure branches of
network-dependent code.
2026-08-12 08:59:57 +00:00
50 changed files with 603 additions and 10893 deletions

283
README.md
View File

@@ -91,21 +91,13 @@ provide:
- `script/lint` — run the linter - `script/lint` — run the linter
- `script/fmt` — format all files (writes) - `script/fmt` — format all files (writes)
- `script/fmt-check` — check formatting (read-only) - `script/fmt-check` — check formatting (read-only)
- `script/check` — run test, test-verify-build, lint, and fmt-check - `script/check` — run test, lint, and fmt-check
- `script/verify-build` — assert the compiled `DEBUG` state of the bundles in - `script/verify-build` — assert the compiled `DEBUG` state of the bundles in
`dist/`: every bundle containing `src/shared/constants.js` must have `DEBUG` `dist/`: every bundle containing `src/shared/constants.js` must have `DEBUG`
off, or on when `AUTISTMASK_DEBUG=1`. Run automatically at the end of off, or on when `AUTISTMASK_DEBUG=1`. Run automatically at the end of
`make build` and `make build-debug`; fails loudly rather than passing if it `make build` and `make build-debug`; fails loudly rather than passing if it
cannot determine a bundle's state. Not part of `make check`, which does not cannot determine a bundle's state. Not part of `make check`, which does not
depend on build artifacts existing. depend on build artifacts existing.
- `script/test-verify-build` — exercise every failure mode of
`script/verify-build` against a fixture tree in a temp dir, asserting the exit
status and the message of each. Part of `make check`; it reads no build
artifacts and writes nothing under `dist/`. The cases that depend on file
permissions cannot mean anything for a process that is not subject to them, so
the harness proves its runner against a mode-000 file before counting them,
dropping to an unprivileged user when run as root; if it cannot, it skips
those cases and says so in a banner rather than passing them.
- `script/docker` — build the Docker image tagged via `script/projectname` - `script/docker` — build the Docker image tagged via `script/projectname`
- `script/cibuild` — CI entrypoint: plain `docker build .` - `script/cibuild` — CI entrypoint: plain `docker build .`
- `script/precommit` — run by the git pre-commit hook; runs `script/check` - `script/precommit` — run by the git pre-commit hook; runs `script/check`
@@ -149,59 +141,11 @@ transfer, and the recovery phrase screen — which wallet types are offered it,
that it holds nothing before the password is accepted, that a wrong password that it holds nothing before the password is accepted, that a wrong password
reveals nothing, that leaving it by either route wipes it — including a leave reveals nothing, that leaving it by either route wipes it — including a leave
taken while the decrypt is still running — and that reopening the popup does not taken while the decrypt is still running — and that reopening the popup does not
land on it. It also covers address removal: which wallets offer the control at land on it. All outbound network is intercepted at the browser level and served
all, that the confirmation states the route back rather than showing an empty from fixtures in `tests/e2e/network.js`, so the run is deterministic and fully
paragraph, that leaving the confirmation removes nothing, and that confirming it
does. All outbound network is intercepted at the browser level and served from
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 confirmation screen, for both a native ETH send and an ERC-20
send: Send disabled while the fee estimate is in flight, enabled once it lands,
the fee block quoting the expected cost and the reserve separately, the distinct
message for an estimate that failed, and the view height staying constant across
every one of those transitions. The load-bearing one is that the spend gate uses
the **reserve** and not the displayed **estimate** — the two are stubbed far
apart on purpose, and the funded and refused sends sit on opposite sides of the
reserve while sitting on the same side of the estimate, so swapping the two in
`src/popup/views/confirmTx.js` fails the suite instead of passing it. That is
what [#154](https://git.eeqj.de/sneak/AutistMask/issues/154) was, and it was
previously correct by reading only.
It also covers the **dApp approval round trips** — the one place where the
content script, the inpage provider, the background worker and the approval
popup all have to work together. A local test page is served by the route
handler on a reserved-TLD origin, gets `window.ethereum` from the shipped
`MAIN`-world content script like any other page, and drives
`eth_requestAccounts`, `personal_sign`, `eth_signTypedData_v4` and
`eth_sendTransaction` through the real prompts. Every signature is recovered in
the runner and compared against the active address, the transaction assertions
run against the raw signed transaction captured at `eth_sendRawTransaction`
rather than against anything the extension reported, rejecting each prompt is
required to return a rejection to the page rather than hang or resolve, and the
password is required to be absent from every message the approval window sends
to the background — with the message that would carry it required to be present,
so that check cannot pass by observing nothing. That last one is the standing
floor under [#157](https://git.eeqj.de/sneak/AutistMask/issues/157).
Three limits of that coverage, none of them papered over. The RPC is stubbed
throughout, so this is **not** a real dApp against a real network with real
funds; that remains a human pass before 1.0.0. The site-connection prompt is
raised through `chrome.action.openPopup()`, and headless Chromium's
browser-action popup is not a page Playwright can see or click, so that one
prompt is driven at the URL the extension itself puts on the action — the same
page and the same approval id, but whether a real toolbar click shows it is not
observable here. And the EIP-1193 error code does not survive the last hop: the
rejection that crosses the boundary carries code 4001 and is asserted to, but
`src/content/inpage.js` rebuilds it as `new Error(message)`, so the calling page
catches an error with no `code` property.
Any test that drives a failure path on purpose declares the `console.error` it
is about to provoke, via `errors.expect()`. That is not a mute: the declaration
consumes exactly one matching record, and a declaration nothing matched fails
its test just as an undeclared error does.
That reporting has one bound worth knowing. Observation ends when the browser That reporting has one bound worth knowing. Observation ends when the browser
context is torn down, and nothing can watch traffic after that, so the run keeps context is torn down, and nothing can watch traffic after that, so the run keeps
collecting for a fixed grace period after the last test returns collecting for a fixed grace period after the last test returns
@@ -243,18 +187,10 @@ and this suite exists because exactly that class of bug shipped twice.
`make test-e2e-firefox` builds `dist/firefox/` and drives the **real popup in a `make test-e2e-firefox` builds `dist/firefox/` and drives the **real popup in a
real Firefox**, installed as an unpacked MV2 temporary add-on via geckodriver. real Firefox**, installed as an unpacked MV2 temporary add-on via geckodriver.
It covers popup load, wallet creation through the UI, the Add Token screen, and It covers popup load, wallet creation through the UI, and the Add Token screen.
the four dApp round trips — `eth_requestAccounts`, `personal_sign`, The suite lives in `tests/e2e/firefox/` and has **no npm dependencies at all**:
`eth_sendTransaction`, and a closed approval window rejecting with EIP-1193 4001 it is a small WebDriver client built on global `fetch` and `child_process`
— driven through the real content script, background page and approval windows. against geckodriver's HTTP API.
The suite lives in `tests/e2e/firefox/`. Its WebDriver client (`driver.js`) has
**no npm dependencies at all**: it is built on global `fetch` and
`child_process` against geckodriver's HTTP API. The dApp fixture (`dapp.js`) and
the assertions do use `ethers`, and have to — a signature is recovered in the
runner rather than believed from the extension, and the stub node has to answer
`eth_sendRawTransaction` with the hash `ethers` computes for the artifact it
sent, or `provider.broadcastTransaction()` refuses the answer.
Unlike the Chrome suite it builds its own container image rather than pulling a Unlike the Chrome suite it builds its own container image rather than pulling a
published one, because no published image carries both a pinned Firefox and a published one, because no published image carries both a pinned Firefox and a
@@ -276,66 +212,41 @@ because BiDi's `browsingContext.navigate` refuses `moz-extension://` outright.
**Any uncaught error from a `moz-extension://` source fails the run**, including **Any uncaught error from a `moz-extension://` source fails the run**, including
errors from the background page, which the suite never navigates to: a `throw` errors from the background page, which the suite never navigates to: a `throw`
at the top of `src/background/index.js` kills the background page and fails at the top of `src/background/index.js` kills the background page and fails
step 1. Content scripts **are** exercised now — the dApp steps drive a page step 1. Content-script errors should arrive by the same route, but this suite
served from loopback, which survives `--network none` — but the _capture_ of a does not exercise it and does not claim it — with `--network none` there is no
content-script error by this route is still unproven: no probe has forced a `http://` page for a content script to be injected into. Errors from add-on
throw inside one and watched it fail the run, so it remains an expectation install and background startup are folded into step 1 rather than discarded.
rather than a demonstrated fact. Errors from add-on install and background Errors are read from the privileged `nsIConsoleService` in Marionette's chrome
startup are folded into step 1 rather than discarded. context and filtered to non-warning entries whose `sourceName` is the extension
origin. That mechanism is not a stylistic choice. WebDriver BiDi's
One error is tolerated rather than fatal, listed in `ALLOWED_ERRORS` in `log.entryAdded` delivers **nothing** for extension pages: on a plain `http://`
`tests/e2e/firefox/run.js` with the issue that will delete it, and printed on page it reports uncaught errors with stack traces, and on the `moz-extension://`
every occurrence so the concession stays visible in the run output. It is popup it reports zero events, because Firefox's remote agent excludes extension
Firefox reporting the site-approval popup's unawaited `sendMessage` settling browsing contexts from BiDi observation. Any harness built on Playwright-BiDi or
after `window.close()` unloaded the context — the same teardown ordering as Puppeteer-BiDi would therefore see nothing and report success, which is exactly
[#275](https://git.eeqj.de/sneak/AutistMask/issues/275), and unsuppressable from the vacuous check this repo has already shipped twice. Do not migrate this suite
the calling code, because `BaseContext.wrapPromise` reports it whether or not a to BiDi.
handler is attached. Errors are read from the privileged `nsIConsoleService` in
Marionette's chrome context and filtered to non-warning entries whose
`sourceName` is the extension origin. That mechanism is not a stylistic choice.
WebDriver BiDi's `log.entryAdded` delivers **nothing** for extension pages: on a
plain `http://` page it reports uncaught errors with stack traces, and on the
`moz-extension://` popup it reports zero events, because Firefox's remote agent
excludes extension browsing contexts from BiDi observation. Any harness built on
Playwright-BiDi or Puppeteer-BiDi would therefore see nothing and report
success, which is exactly the vacuous check this repo has already shipped twice.
Do not migrate this suite to BiDi.
Two limits are worth knowing, both real differences from the Chrome suite: Two limits are worth knowing, both real differences from the Chrome suite:
- **Error capture is poll-based, not event-streamed.** The console is drained at - **Error capture is poll-based, not event-streamed.** The console is drained at
each step boundary, so an error is attributed to the step it was drained each step boundary, so an error is attributed to the step it was drained
after, not to a moment within it. The window that is drained runs from add-on after, not to a moment within it. The window that is drained runs from add-on
install to **≈1.5s** after the last step returns — a 500ms settle, a 1000ms install to one second after the last step returns; an error logged later than
tail sleep and two drain round trips — and then the browser is torn down. That that ~1s tail is missed entirely, because the browser is torn down first.
cut-off is not a hard boundary: with throws scheduled at fixed offsets, three Within that window nothing is dropped, but an error cannot be located within a
runs reported everything up to +1.5s and one of the three also reported +1.6s, step the way the Chrome suite's `pageerror` events can.
so an error landing near it may or may not be seen, and anything well past it - **Nothing is stubbed, which inverts the coverage of network-dependent code.**
is not. Inside the window there is no race — each drain reads and clears the There is no fixture layer; the container runs with `--network none` instead,
console in a single chrome round trip, so an error logged mid-drain lands in so the run is offline and deterministic and no request can escape. The
that batch or the next rather than being destroyed unread — but there is a extension swallows its own fetch failures, so the flows are unaffected — but
**capacity limit**: `nsIConsoleService` keeps a ring buffer of 250 messages every network call fails, so only the _failure_ branches of code that depends
and silently evicts the oldest, so more than 250 console messages between two on one are ever executed. A `ReferenceError` in the success path of
drains destroys the excess unread. 400 throws inside one step are reported as `renderTransactions`, or of price or balance rendering, passes this suite
exactly the newest 250, three runs running. That buffer is shared with green. The offline run is also weaker than the Chrome suite's interception: it
Firefox's own console noise; a clean run peaks at 4 of 250 at the install proves nothing got out, but it cannot report which requests were attempted.
drain and 0 at every later drain, so the three steps here have wide headroom, Closing that gap needs a fixture layer, deliberately out of scope for this
but a step that logs heavily could evict unread errors. What poll-based costs harness.
is location, not coverage: an error cannot be placed within a step the way the
Chrome suite's `pageerror` events place it.
- **Almost nothing is stubbed, which inverts the coverage of network-dependent
code.** The container still runs with `--network none`, so the run is offline
and no request can escape. The one thing it can reach is the loopback fixture
in `tests/e2e/firefox/dapp.js`, which serves the dApp page and a JSON-RPC node
and which the extension's `rpcUrl` is pointed at for the dApp steps; a
JSON-RPC method that fixture does not model fails the run rather than
answering `null`. Everything else — Blockscout, the price feed, the phishing
blocklist — has no fixture and simply fails, and the extension swallows its
own fetch failures, so only the _failure_ branches of that code are ever
executed. A `ReferenceError` in the success path of `renderTransactions`, or
of price rendering, passes this suite green. The offline run is also weaker
than the Chrome suite's interception for those calls: it proves nothing got
out, but it cannot report which requests were attempted.
Neither `make test-e2e` nor `make test-e2e-firefox` is part of `make check` or 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
@@ -386,7 +297,6 @@ src/
prices.js — ETH/USD and token/USD via CoinDesk API prices.js — ETH/USD and token/USD via CoinDesk API
scamlist.js — known fraud contract addresses scamlist.js — known fraud contract addresses
state.js — persisted state (extension storage) state.js — persisted state (extension storage)
symbolSpoof.js — the known-symbol spoof rule, shared by all surfaces
tokenList.js — top ERC-20 tokens by market cap (hardcoded) tokenList.js — top ERC-20 tokens by market cap (hardcoded)
transactions.js — tx history fetching + anti-poisoning filters transactions.js — tx history fetching + anti-poisoning filters
uniswap.js — Uniswap Universal Router calldata decoder uniswap.js — Uniswap Universal Router calldata decoder
@@ -616,12 +526,11 @@ Which tokens an address shows is decided by `fetchTokenBalances()` in
tokens do appear without the user adding them. An ERC-20 is shown when its tokens do appear without the user adding them. An ERC-20 is shown when its
balance is nonzero and it is in the bundled known-token list, is tracked by the balance is nonzero and it is in the bundled known-token list, is tracked by the
user, or has 1,000 or more holders; a token claiming a symbol from the bundled user, or has 1,000 or more holders; a token claiming a symbol from the bundled
list from any other contract address is always dropped, and so is any token list from any other contract address is always dropped. That filter is
claiming a symbol that belongs to the native asset and therefore has no unconditional — the "Hide tokens with fewer than 1,000 holders" setting governs
legitimate contract at all (`"ETH"`). That filter is unconditional — the "Hide the transaction history and the send-screen token selector, not this list.
tokens with fewer than 1,000 holders" setting governs the transaction history Tracked tokens with a zero balance are listed as well while "Show tracked tokens
and the send-screen token selector, not this list. Tracked tokens with a zero with zero balance" is on.
balance are listed as well while "Show tracked tokens with zero balance" is on.
#### Navigation #### Navigation
@@ -688,9 +597,8 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
- Wallet list: each wallet shows its name (tap to rename inline) and a "+" - Wallet list: each wallet shows its name (tap to rename inline) and a "+"
button for HD and xprv wallets, then one block per address with "Address button for HD and xprv wallets, then one block per address with "Address
N" (bold when active), the ENS name if resolved, the full address, an N" (bold when active), the ENS name if resolved, the full address, an
`[info]` button, an `[x]` button (only on HD and xprv wallets holding more `[info]` button, the address USD total, and a balance line for ETH and for
than one address), the address USD total, and a balance line for ETH and each token shown for that address
for each token shown for that address
- "Recent Transactions": up to 25 transactions merged across every address - "Recent Transactions": up to 25 transactions merged across every address
of every wallet, deduplicated by hash and filtered of every wallet, deduplicated by hash and filtered
- "Add additional wallet..." link at bottom - "Add additional wallet..." link at bottom
@@ -700,7 +608,6 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
- Tap wallet name → inline rename field (no screen change) - Tap wallet name → inline rename field (no screen change)
- "+" on wallet → derives the next address inline (no screen change) - "+" on wallet → derives the next address inline (no screen change)
- `[info]` on address → **AddressDetail** - `[info]` on address → **AddressDetail**
- `[x]` on address → **DeleteAddress**
- "Send" → **Send** (refuses with a flash message on a zero balance) - "Send" → **Send** (refuses with a flash message on a zero balance)
- "Receive" → **Receive** (shows active address QR) - "Receive" → **Receive** (shows active address QR)
- Tap home tx row → **TransactionDetail** - Tap home tx row → **TransactionDetail**
@@ -1011,12 +918,7 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
- "Hide fake tokens impersonating a known symbol" checkbox - "Hide fake tokens impersonating a known symbol" checkbox
- "Hide tokens with fewer than 1,000 holders" checkbox - "Hide tokens with fewer than 1,000 holders" checkbox
- "Hide transactions from detected fraud contracts" checkbox - "Hide transactions from detected fraud contracts" checkbox
- "Hide dust transactions below N gwei" checkbox + threshold input. The - "Hide dust transactions below N gwei" checkbox + threshold input
threshold is plain decimal digits, a whole number of gwei, zero or
greater (zero hides nothing). Anything else — a fraction, a negative,
a value carrying its unit, hex (`0x10`) or exponent (`1e3`) notation —
is refused with a flash message and the field snaps back to the stored
threshold, so a number the user did not type is never stored.
- Allowed Sites: list with remove buttons - Allowed Sites: list with remove buttons
- Denied Sites: list with remove buttons - Denied Sites: list with remove buttons
- About: project link, license, author, version, release date, and the - About: project link, license, author, version, release date, and the
@@ -1084,55 +986,6 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
nothing deleted nothing deleted
- "Back" → previous screen (Settings) - "Back" → previous screen (Settings)
#### DeleteAddress (`delete-address-confirm`)
- **When**: User tapped the `[x]` next to an address on Home. Offered only on HD
and xprv wallets holding more than one address: the last address of a wallet
is never removable, and a key wallet has exactly one.
- **Elements**:
- "Back" button, "Remove Address" heading
- The address's own label ("Address N") and its wallet's name
- The full address (color dot, etherscan link, tap to copy), with the ENS
name above it if resolved
- Explanation that this only stops the wallet tracking the address: nothing
is destroyed, no key is deleted, and funds stay where they are
- The route back, stated with its limit, because the obvious two are both
refused: "+" derives the next unused index (`nextIndex` is a high-water
mark), and re-importing the wallet's key material is rejected as a
duplicate by `findWalletByXpub` while the wallet is still present. What
works is deleting the whole wallet in Settings — password-gated, and it
destroys the stored secret — then importing again, whereupon
`scanForAddresses()` rediscovers the address **only if it has on-chain
activity**. An address that was never used is not found by that scan. The
text is written by `recoveryPathText()` rather than sitting in
`index.html`, so it can name the wallet's own kind of key material: an
xprv wallet has no recovery phrase to re-import.
- A warning when the address holds anything, ETH or any tracked ERC-20,
followed by the holdings themselves via `balanceLinesForAddress()` and the
USD total via `getAddressValueUsd()`. The sentence names no figure of its
own: the lines round to four decimals, so a sentence built from a rounded
number would report `0.0000 ETH` for an address holding real money. The
predicate is `addressHoldsFunds()` in `src/popup/views/helpers.js`,
unrounded and token-aware. A balance is a warning, never a refusal.
- The rule that a wallet always keeps at least one address, and that
removing the last one means deleting the wallet from Settings
- Error line
- "Remove Address" button
- **Transitions**:
- "Remove Address" → removes the address and its site permissions, then →
previous screen (Home) with an "Address removed." flash message
- "Back" → previous screen (Home), nothing removed
- **Deliberately not password-gated**, unlike DeleteWallet: a password gates the
disclosure or destruction of a secret, and this does neither. The address
stays derivable from key material the wallet still holds.
- The active address moves only if it was the address removed, and then to the
wallet's first remaining address, with `AUTISTMASK_ACTIVE_CHANGED` broadcast
so a connected site stops being told about an address the user removed
(`src/shared/walletDelete.js`). A selection in any other wallet is left alone;
one in this wallet follows the splice.
- The wallet's derivation counter (`nextIndex`) is not rewound, so "+" derives a
fresh address rather than handing back the one just removed.
#### SettingsAddToken (`settings-addtoken`) #### SettingsAddToken (`settings-addtoken`)
- **When**: User tapped "+ Add token" in Settings. Tokens added here are tracked - **When**: User tapped "+ Add token" in Settings. Tokens added here are tracked
@@ -1178,12 +1031,7 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
- **When**: A connected website requests a transaction via - **When**: A connected website requests a transaction via
`eth_sendTransaction`. Always opened in a separate popup window by the `eth_sendTransaction`. Always opened in a separate popup window by the
background script (`windows.create()`), because the request is triggered background script (`windows.create()`), because the request is triggered
programmatically rather than by a user gesture. The background populates the programmatically rather than by a user gesture.
transaction (nonce, gas limit, fees, chain id) against the RPC node _before_
opening the window, so the screen shows a complete transaction and the signed
artifact can be compared with it field for field. A request that cannot be
populated — unreachable node, reverting gas estimate — opens no window and is
failed back to the site.
- **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
@@ -1195,16 +1043,13 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
- Contract: color dot + full address + etherscan link (or "contract - Contract: color dot + full address + etherscan link (or "contract
creation"), token symbol label if known creation"), token symbol label if known
- Value: amount in ETH (4 decimal places, USD in parentheses) - Value: amount in ETH (4 decimal places, USD in parentheses)
- Network fee (max): gas limit × fee per gas in ETH (4 decimal places, USD
in parentheses), with the gas limit and the fee per gas in gwei below it
- Network and nonce
- Raw data: full calldata displayed inline (shown if present) - Raw data: full calldata displayed inline (shown if present)
- Password input and an error line - Password input and an error line
- "Confirm" / "Reject" buttons - "Confirm" / "Reject" buttons
- **Transitions**: - **Transitions**:
- "Confirm" (correct password) → decrypts and signs the transaction it was - "Confirm" (correct password) → decrypts and signs in the popup, hands the
shown, exactly as shown, hands the signed transaction to the background to signed transaction to the background to broadcast, then → **WaitTx** in
broadcast, then → **WaitTx** in the same popup window the same popup window
- "Confirm" (wrong password) → error line, no screen change - "Confirm" (wrong password) → error line, no screen change
- "Reject" → closes popup (returns rejection to background) - "Reject" → closes popup (returns rejection to background)
- Popup window closed without answering → the request is rejected with - Popup window closed without answering → the request is rejected with
@@ -1503,15 +1348,14 @@ indexes it as a real token transfer.
that is the only thing that populates it. In the transaction history the check that is the only thing that populates it. In the transaction history the check
is the "Hide fake tokens impersonating a known symbol" setting, on by default; is the "Hide fake tokens impersonating a known symbol" setting, on by default;
with it off, spoofed transfers are shown and no new blocklist entries are with it off, spoofed transfers are shown and no new blocklist entries are
learned from them. The send-screen token selector and the balance list apply learned from them. The send-screen token selector applies the same check
the same check unconditionally, because they decide which tokens the user can unconditionally, because it decides which tokens the user can act on rather
act on and what the user believes they own rather than what the history than what the history displays. The balance list applies it unconditionally
displays. All three surfaces read the rule from `src/shared/symbolSpoof.js`, too, but not identically: it exempts symbols that `KNOWN_SYMBOLS` maps to
so they cannot answer the question differently. A symbol the list maps to no `null`, and `"ETH"` is the only one. So the fake "Ethereum" token above is
contract at all — `"ETH"`, the native asset, is the only one — may be borne by filtered from the transaction history and from the send selector, but a
no contract, so every ERC-20 claiming it is a spoof on all three. The user's fake-`ETH` ERC-20 that clears the balance list's own 1,000-holder floor — or
real ETH balance is not an ERC-20 and is read over RPC, so the rule never sees that the user tracked manually — is still shown in the balance list.
it.
- **Low-holder token filtering**: Token transfers from ERC-20 contracts with - **Low-holder token filtering**: Token transfers from ERC-20 contracts with
fewer than 1,000 holders are hidden from transaction history by default. fewer than 1,000 holders are hidden from transaction history by default.
@@ -1547,12 +1391,13 @@ indexes it as a real token transfer.
a sharp tool — users who understand the risks can configure the wallet to show a sharp tool — users who understand the risks can configure the wallet to show
everything unfiltered, unix-style. All four settings govern the transaction everything unfiltered, unix-style. All four settings govern the transaction
history; what else each one reaches varies. The known-symbol check also runs history; what else each one reaches varies. The known-symbol check also runs
unconditionally on the send-screen token selector and on the balance list, in unconditionally on the send-screen token selector, and on the balance list
both cases identically to the history. The fraud contract blocklist is applied except for symbols mapped to `null` (`"ETH"` alone), which the balance list
unconditionally on that selector and is not consulted by the balance list at does not filter. The fraud contract blocklist is applied unconditionally on
all. The low-holder setting also gates the send selector, while the balance that selector and is not consulted by the balance list at all. The low-holder
list's own 1,000-holder floor is unconditional (see Data Model). The dust setting also gates the send selector, while the balance list's own
threshold applies to the transaction history alone. 1,000-holder floor is unconditional (see Data Model). The dust threshold
applies to the transaction history alone.
#### Phishing Domain Protection #### Phishing Domain Protection
@@ -1624,7 +1469,7 @@ Currently supported:
### Wallet Management ### Wallet Management
- [x] Delete wallet (with confirmation) - [x] Delete wallet (with confirmation)
- [x] Delete address from HD wallet (with confirmation) - [ ] Delete address from HD wallet (with confirmation)
- [x] Show wallet's recovery phrase (requires password) - [x] Show wallet's recovery phrase (requires password)
### Transactions ### Transactions

157
TODO.md
View File

@@ -45,167 +45,18 @@ undefined identifiers, which is how
# Completed Steps # Completed Steps
- 2026-08-12: One shared extension-API module,
[`src/shared/browserApi.js`](src/shared/browserApi.js), is the only place in
the tree that names `browser` or `chrome`. Every call site returns a promise;
`runtime.lastError` is gone. The same commit gives the Firefox suite the four
dApp round trips — `eth_requestAccounts`, `personal_sign`,
`eth_sendTransaction` and a closed approval window rejecting with EIP-1193
4001 — against a page and a JSON-RPC node served from loopback, which survives
`--network none`. **The premise of
[#153](https://git.eeqj.de/sneak/AutistMask/issues/153) does not survive that
harness**: Firefox's `browser.*` honours a trailing Chrome-style callback and
populates `runtime.lastError`, both measured directly on Firefox 153.0.3, and
all four flows pass against the unconverted code. What landed is a uniformity
and coverage change, not a repair of a broken target.
- 2026-08-12: EIP-1193 error codes now reach the page. `src/content/inpage.js`
rebuilt every failure as `new Error(error.message)`, so the code the
background produced and the content script relayed intact was dropped in the
last hop and a dApp checking `err.code === 4001` saw `undefined` — a wallet
the user deliberately declined was indistinguishable from one that broke. The
provider now rejects with a `ProviderRpcError` carrying `code` and, where the
boundary sent one, `data`, passed through verbatim rather than matched against
a list, so 4001, 4100 and 4902 all arrive and a future code needs no edit
here. An error the background sent with no code stays a plain `Error` with no
`code` property, and `message` is unchanged in every case. All four request
entry points (`request`, `enable`, `send`, `sendAsync`) are covered by
`tests/inpageErrors.test.js`, and the e2e probe that printed the missing code
now requires it on the page's Error as well as on the wire, for all four
rejected flows ([#274](https://git.eeqj.de/sneak/AutistMask/issues/274)).
- 2026-08-12: `KNOWN_SYMBOLS` now maps a symbol to the set of contract addresses
that bear it, not to one of them. A ticker is not unique: seven of the 512
bundled tokens — `FRAX`, `REUSD`, `TON`, `EURE`, `MSUSD`, `MUSD` and `JPYC`
share a symbol with another bundled entry at a different real contract, and
the table, built from the list first-wins, kept only the earlier one. The
other seven were judged spoofs of their own symbol at their own address and
hidden from the balance list, the history and the send selector, so a holder
could not spend them. Both contracts of each pair come from the same CoinGecko
fetch of 2026-02-27, so neither was stale and neither was dropped.
`isSpoofedSymbol()` asks set membership instead of equality, which does not
loosen the rule — a contract outside the set is still a spoof — and a test now
walks `TOKENS` asserting no bundled token is filtered at its own address,
which is the walk the suite lacked
([#276](https://git.eeqj.de/sneak/AutistMask/issues/276)).
- 2026-08-12: The dApp approval round trips are driven end to end in the
browser. A test page served by the harness speaks EIP-1193 to the real inpage
provider through the real content script, background worker and approval popup
for `eth_requestAccounts`, `personal_sign`, `eth_signTypedData_v4` and
`eth_sendTransaction`. Every signature is recovered and compared against the
active address, the transaction is checked against the bytes handed to the
stubbed RPC, each rejection must reach the page as a rejection, and the
password must appear in no message the approval window sends — the assertion
that gives [#157](https://git.eeqj.de/sneak/AutistMask/issues/157) a permanent
floor. This does not discharge a real dApp with real funds against mainnet
([#183](https://git.eeqj.de/sneak/AutistMask/issues/183)).
- 2026-08-12: The known-symbol spoof rule now judges the symbol a user actually
sees. `isSpoofedSymbol()` normalizes before the lookup — NFKC, then every
character that paints nothing removed (the format and default-ignorable
characters, plus U+007F), then trimmed — so `" ETH "`, a no-break space, a
zero-width space, a Hangul filler, a variation selector, a DELETE and a
fullwidth `` are all caught on the balance list, the history and the
send selector at once. Confusables that are distinct letters (Cyrillic `Е`),
bidi reordering and the visible C0/C1 controls — which measure 48.00px, a box,
in the pinned e2e Chromium where an invisible prefix measures 32.00px — stay
knowingly open and are asserted as open in the suite. No bundled symbol
contains whitespace or a non-ASCII character, so nothing legitimate is newly
filtered; the balance list's token-type gate also became case-insensitive,
which no longer drops a real holding if an explorer writes `erc-20`
([#260](https://git.eeqj.de/sneak/AutistMask/issues/260)).
- 2026-08-12: A containerized Firefox end-to-end harness - 2026-08-12: A containerized Firefox end-to-end harness
(`make test-e2e-firefox`) drives the real popup in a real Firefox with the MV2 (`make test-e2e-firefox`) drives the real popup in a real Firefox with the MV2
build installed as a temporary add-on. Zero npm dependencies — a WebDriver build installed as a temporary add-on. Zero npm dependencies — a WebDriver
client over `fetch` against geckodriver — with `node`, Firefox 153.0.3 and client over `fetch` against geckodriver — with `node`, Firefox 153.0.3 and
geckodriver 0.36.0 all pinned by digest. Uncaught errors are read from the geckodriver 0.36.0 all pinned by digest. Uncaught errors are read from the
privileged console service in Marionette's chrome context, because BiDi privileged console service in Marionette's chrome context, because BiDi
`log.entryAdded` reports nothing at all for extension pages; each drain reads `log.entryAdded` reports nothing at all for extension pages, and errors logged
and clears the console in one chrome round trip, so no error is destroyed during add-on install and background startup are folded into step 1 instead of
unread by the drain itself, and errors logged during add-on install and being cleared; demonstrated discriminating by exiting 1 on a `throw` at the
background startup are folded into step 1 instead of being cleared. The two top of `src/background/index.js` and on a build with one import removed, and 0
measured limits are documented rather than claimed away: the console ring
buffer holds 250 messages (a clean run peaks at 4), and the drained window
ends ≈1.5s after the last step returns. Demonstrated discriminating by exiting
1 on a `throw` at the top of `src/background/index.js`, on a build with one
import removed, on a `setTimeout` throw whose UI assertions all pass, on an
unhandled `Promise.reject` and on an undefined identifier in `home.js`, and 0
on the branch as it stands on the branch as it stands
([#184](https://git.eeqj.de/sneak/AutistMask/issues/184)). ([#184](https://git.eeqj.de/sneak/AutistMask/issues/184)).
- 2026-08-12: The transaction a dApp asks for is now populated in the background
before the approval window opens, so the object the user is shown is the
object the signed artifact is verified against — nonce, gas limit and every
fee field are compared exactly instead of being left to the ceilings, which
stay as a backstop against what a lying RPC node can talk the wallet into
displaying. The approval also pins the address it was raised for, so an
address switch between approval and signing refuses rather than signing from
an account the screen never named, and a request naming an address that is not
the active one is refused outright. The approval screen now shows the fee, gas
limit, network and nonce it vouches for
([#216](https://git.eeqj.de/sneak/AutistMask/issues/216)).
- 2026-08-12: The restored navigation stack is filtered against
`RESTORABLE_VIEWS` on load, truncated at the first entry the popup would not
render so that every surviving entry keeps the Back target it had. Back after
reopening can no longer land on a view the popup declined to restore, such as
`export-privkey` or `show-phrase`
([#224](https://git.eeqj.de/sneak/AutistMask/issues/224)). Restorable views in
the stack are still unhidden without being re-rendered; that is tracked
separately in ([#268](https://git.eeqj.de/sneak/AutistMask/issues/268)).
- 2026-08-12: One wording for a rejected password on every screen that asks for
one — the send confirmation and the delete-wallet confirmation no longer say
"Wrong password." (a fragment, which `RULES.md` Language & Labeling forbids)
and the two reveal screens no longer say "not correct", so all five
`decryptWithPassword` call sites now show the sentence the dApp approval paths
introduced. Strings only, no behaviour change, and each error container
measured at a 360px viewport in the pinned Playwright container
([#172](https://git.eeqj.de/sneak/AutistMask/issues/172)).
- 2026-08-12: Closed the empty-array hole in the end-to-end unstubbed-request
guard. `batch.every()` is vacuously true on `[]`, so a POST with body `[]` was
answered `200 []` instead of failing the suite; the guard now rejects an empty
batch, demonstrated green-before/red-after with a throwaway probe. The comment
claiming `postData()` returns `null` for undecodable bodies was corrected to
the two real paths — an absent or empty body decodes to `null`, a binary body
decodes lossily into invalid JSON
([#187](https://git.eeqj.de/sneak/AutistMask/issues/187)).
- 2026-08-12: The transaction confirmation screen has browser coverage. The
end-to-end suite reaches ConfirmTx for both the native ETH and the ERC-20 path
off a funded-balance fixture, and asserts the pending, funded, over-balance
and estimate-failed states, the fee block quoting the estimate and the reserve
separately, and a constant view height across every one of those transitions.
The load-bearing assertion is that the spend gate reads the reserve and not
the displayed estimate: swapping the two fails the suite
([#238](https://git.eeqj.de/sneak/AutistMask/issues/238)).
- 2026-08-12: The dust threshold field now explains a rejection instead of
snapping back in silence, with the parse in a pure, unit-tested module that
accepts plain decimal digits only — hex and exponent notation are refused
rather than read as 16 and 1000
([#233](https://git.eeqj.de/sneak/AutistMask/issues/233)).
- 2026-08-12: Approval verification became an allowlist — transaction type
restricted to 0/1/2 so an EIP-7702 delegation can no longer ride along on an
approved transfer, every consequential field compared, the artifact
re-serialized from the checked fields alone and its exact bytes required to be
the canonical encoding of what was broadcast. One approval now yields at most
one broadcast, and every path that retires a pending approval — popup close,
active-address change, a late reject — goes through a single chokepoint that
refuses to settle an attempt already claimed for signing and broadcast
([#174](https://git.eeqj.de/sneak/AutistMask/issues/174)).
- 2026-08-12: An address can be removed from an HD or xprv wallet behind a
confirmation screen that states nothing is destroyed, sharing the deletion
state transitions with wallet deletion so the selection, site permissions and
active-address broadcast follow the same rules
([#162](https://git.eeqj.de/sneak/AutistMask/issues/162)).
- 2026-08-12: The known-symbol spoof rule moved into `src/shared/symbolSpoof.js`
and is now the only copy. The balance list had exempted symbols the token list
maps to `null``"ETH"` alone — so a fake ETH ERC-20 was hidden from the
transaction history and the Send selector but listed as a holding named ETH. A
symbol with no legitimate contract may now be borne by no contract on any of
the three surfaces, and the native exemption is "has no contract address", so
a second null-mapped symbol needs no call-site change. The user's real ETH
balance is read over RPC and never passes through the rule
([#235](https://git.eeqj.de/sneak/AutistMask/issues/235)).
- 2026-08-12: `script/verify-build`'s failure modes are now a committed target,
`script/test-verify-build`, run by `make check`. It asserts the exit status
and the message of every case against a fixture tree in a temp dir, and drops
privileges (proving the runner against a mode-000 file first) for the cases
that only mean something when file permissions are in force
([#227](https://git.eeqj.de/sneak/AutistMask/issues/227)).
- 2026-08-12: WaitTx lifecycle: a receipt and the 60-second timeout can no - 2026-08-12: WaitTx lifecycle: a receipt and the 60-second timeout can no
longer both render on one tick, no timer or in-flight lookup outlives its longer both render on one tick, no timer or in-flight lookup outlives its
wait, a failed receipt lookup no longer counts as a timeout (but six in a row wait, a failed receipt lookup no longer counts as a timeout (but six in a row

View File

@@ -311,15 +311,10 @@ pages. When a site requests access to your wallet:
time. time.
When a connected site requests a transaction, a separate approval popup appears When a connected site requests a transaction, a separate approval popup appears
showing the transaction details (from, to, value, data, network fee, network and showing the transaction details (from, to, value, data). You must enter your
nonce). Every one of those values is checked against the transaction that is password and click "Confirm" to authorize it. Message and typed-data signature
actually signed before anything is broadcast, so what you read on that screen is requests work the same way, with a "Sign" button, and also require your
what goes out or nothing does. The popup appears once the wallet has worked out password.
the fee and gas from the network, which takes a moment; if that fails, no popup
appears and the site is told the transaction could not be prepared. You must
enter your password and click "Confirm" to authorize it. Message and typed-data
signature requests work the same way, with a "Sign" button, and also require
your password.
If the requesting site's domain is on the phishing blocklist, all three approval If the requesting site's domain is on the phishing blocklist, all three approval
screens show a red phishing warning before you decide. screens show a red phishing warning before you decide.

View File

@@ -1,13 +1,12 @@
#!/bin/sh #!/bin/sh
# script/check: run all checks (test, test-verify-build, lint, fmt-check). # script/check: run all checks (test, lint, fmt-check). Our own
# Our own extension to scripts-to-rule-them-all. Must not modify any files. # extension to scripts-to-rule-them-all. Must not modify any files.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() { main() {
"$SCRIPT_DIR/test" "$SCRIPT_DIR/test"
"$SCRIPT_DIR/test-verify-build"
"$SCRIPT_DIR/lint" "$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check" "$SCRIPT_DIR/fmt-check"
} }

View File

@@ -1,444 +0,0 @@
#!/bin/sh
# script/test-verify-build: exercise every failure mode of
# script/verify-build. Our own extension to scripts-to-rule-them-all, run
# from script/check so make check covers it.
#
# Why this exists: verify-build is the build-integrity guard, and three
# separate reviews of it each found a fresh vacuous pass — the grep exit-2
# conflation, the discarded find status, the line-delimited walk. Every one
# was caught by someone building a tree by hand, because nothing in make check
# could catch it. This is that hand battery, committed and automated.
#
# Each case asserts the exit status AND a substring of the message. A guard
# that fails for the wrong reason (right status, different fault) is itself a
# defect, so matching the status alone would not be a test of anything.
#
# The fixture is a temp tree containing script/verify-build as a SYMLINK to
# the real script: verify-build takes its ROOT from dirname "$0"/.., so it
# operates on the fixture's dist/ and never reads or writes the repo's build
# output. The symlink rather than a copy is what makes a deliberate break in
# the real script fail here.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
VERIFY_BUILD="$ROOT/script/verify-build"
MARKER_ON="autistmask-build-debug=on"
MARKER_OFF="autistmask-build-debug=off"
NEWLINE='
'
PASSED=0
FAILED=0
SKIPPED=0
SKIPPED_NAMES=""
# The command prefix that runs the permission-dependent cases as a user who
# is actually subject to file permissions, and whether those cases can run at
# all. Both are decided by probe_permission_runner, never assumed.
UNPRIV=""
PERM_ENABLED=no
PERM_HOW=""
WORK=""
cleanup() {
[ -n "$WORK" ] || return 0
# The cases chmod 000 files and directories on purpose.
chmod -R u+rwX "$WORK" 2>/dev/null || true
rm -rf "$WORK"
}
trap cleanup EXIT INT TERM
WORK="$(mktemp -d "${TMPDIR:-/tmp}/autistmask-test-verify-build.XXXXXX")"
FIXTURE="$WORK/fixture"
# verify-build mktemps its dist/ listing under TMPDIR. Pointing that inside
# our work dir keeps the run leaving no residue, and keeps it writable for the
# unprivileged user the permission cases run as.
TMPDIR="$WORK/tmp"
export TMPDIR
mkdir -p "$TMPDIR"
chmod 1777 "$TMPDIR"
chmod 755 "$WORK"
# --- fixture ---------------------------------------------------------------
# A stand-in for an emitted bundle: some text plus one marker literal, which
# is all verify-build reads out of the real thing.
write_bundle() {
printf 'var a=1;/* %s */\nvar b=2;\n' "$2" >"$1"
}
# A dist/ shaped like a real build: two listed bundles under different
# browsers, an unlisted subtree to make unwalkable, and unlisted files that
# carry no marker and must not be objected to.
build_fixture() {
chmod -R u+rwX "$FIXTURE" 2>/dev/null || true
rm -rf "$FIXTURE"
mkdir -p "$FIXTURE/script"
ln -s "$VERIFY_BUILD" "$FIXTURE/script/verify-build"
mkdir -p "$FIXTURE/dist/chrome/src/popup" \
"$FIXTURE/dist/chrome/src/content" \
"$FIXTURE/dist/firefox/src/popup"
write_bundle "$FIXTURE/dist/chrome/src/popup/index.js" "$MARKER_OFF"
write_bundle "$FIXTURE/dist/firefox/src/popup/index.js" "$MARKER_OFF"
printf 'body{color:#000}\n' >"$FIXTURE/dist/styles.css"
printf 'var c=3;\n' >"$FIXTURE/dist/chrome/src/content/content.js"
{
echo "dist/chrome/src/popup/index.js"
echo "dist/firefox/src/popup/index.js"
} >"$FIXTURE/dist/constants-bundles.txt"
# Readable and traversable by the unprivileged user the permission cases
# run as, before those cases take that away again on purpose.
chmod -R a+rX "$FIXTURE"
}
# --- permission runner ------------------------------------------------------
# Run a command through the current unprivileged runner. Unquoted on purpose:
# UNPRIV is a command prefix that has to word-split.
run_unpriv() {
# shellcheck disable=SC2086
$UNPRIV "$@"
}
# Decide whether the permission-dependent cases can run, and prove it rather
# than assuming it.
#
# The problem: the CI image declares no USER, so CI runs as root, and root is
# not subject to file permissions — chmod 000 stops neither find nor grep. A
# permission case run as root passes vacuously, which is worse than no case at
# all because it reads as coverage.
#
# So the runner is validated with two probes before any permission case is
# counted:
#
# - a mode-644 file MUST be readable through it. If not, the runner itself
# is broken (missing helper, no such user, sandbox), and every case run
# through it would fail for the wrong reason.
# - a mode-000 file MUST NOT be readable through it. If it is, permissions
# are not in force and the cases would pass without proving anything.
#
# Unprivileged: the runner is empty and both probes are about this process,
# which is the honest answer. Root: setpriv and runuser are tried, both
# present in the pinned CI base image. Only when no candidate passes both
# probes are the cases skipped, and a skipped run says so unmistakably.
probe_permission_runner() {
_probe="$WORK/probe"
mkdir -p "$_probe"
printf 'readable\n' >"$_probe/public"
printf 'secret\n' >"$_probe/private"
chmod 755 "$_probe"
chmod 644 "$_probe/public"
chmod 000 "$_probe/private"
if [ "$(id -u)" -eq 0 ]; then
_candidates="setpriv|setpriv --reuid=65534 --regid=65534 --clear-groups --
runuser|runuser -u nobody --"
else
_candidates="direct|"
fi
_tried=""
_saved_ifs="$IFS"
IFS="$NEWLINE"
for _line in $_candidates; do
IFS="$_saved_ifs"
_label="${_line%%|*}"
_cmd="${_line#*|}"
_tried="${_tried:+$_tried, }$_label"
if [ -n "$_cmd" ]; then
_bin="${_cmd%% *}"
command -v "$_bin" >/dev/null 2>&1 || continue
fi
UNPRIV="$_cmd"
# Broken or unusable runner: the cases would fail for the wrong
# reason. Reaching the script under test is part of usable.
run_unpriv cat "$_probe/public" >/dev/null 2>&1 || continue
run_unpriv cat "$VERIFY_BUILD" >/dev/null 2>&1 || continue
# Permissions not in force through this runner: the cases would pass
# without testing anything.
if run_unpriv cat "$_probe/private" >/dev/null 2>&1; then
continue
fi
PERM_ENABLED=yes
PERM_HOW="$_label"
IFS="$_saved_ifs"
return 0
done
IFS="$_saved_ifs"
UNPRIV=""
PERM_ENABLED=no
PERM_HOW="$_tried"
}
# --- case runner ------------------------------------------------------------
# check_case <name> <perm:yes|no> <mode:release|debug> <status> <text> <setup>
#
# Rebuilds the fixture, applies <setup> inside it, runs verify-build, and
# requires both the exit status and the message. <perm> marks a case that only
# means anything when file permissions are in force.
check_case() {
_name="$1"
_perm="$2"
_mode="$3"
_want_status="$4"
_want_text="$5"
_setup="$6"
if [ "$_perm" = yes ] && [ "$PERM_ENABLED" != yes ]; then
SKIPPED=$((SKIPPED + 1))
SKIPPED_NAMES="$SKIPPED_NAMES## - $_name$NEWLINE"
echo " SKIP (permissions not in force): $_name"
return 0
fi
build_fixture
if ! (cd "$FIXTURE" && "$_setup") >/dev/null 2>&1; then
FAILED=$((FAILED + 1))
echo " FAIL: $_name"
echo " the case's own setup failed, so nothing was tested."
return 0
fi
if [ "$_mode" = debug ]; then
_debug=1
else
_debug=""
fi
# Exported rather than set as a command prefix: run_unpriv is a function,
# and an assignment prefixed to a function call is not portable.
AUTISTMASK_DEBUG="$_debug"
export AUTISTMASK_DEBUG
_status=0
if [ "$_perm" = yes ]; then
_out="$(run_unpriv "$FIXTURE/script/verify-build" 2>&1)" || _status=$?
else
_out="$("$FIXTURE/script/verify-build" 2>&1)" || _status=$?
fi
_ok=yes
_why=""
if [ "$_status" -ne "$_want_status" ]; then
_ok=no
_why="exit status $_status, wanted $_want_status"
fi
# Same discipline verify-build itself applies to grep: 0 and 1 are
# answers, anything else is not, and must not be read as "no match".
_g=0
printf '%s\n' "$_out" | grep -q -F -e "$_want_text" || _g=$?
case "$_g" in
0) ;;
1)
_ok=no
_why="${_why:+$_why; }message did not contain: $_want_text"
;;
*)
_ok=no
_why="${_why:+$_why; }grep exited $_g matching the message, so the
message was never checked"
;;
esac
if [ "$_ok" = yes ]; then
PASSED=$((PASSED + 1))
echo " ok: $_name"
return 0
fi
FAILED=$((FAILED + 1))
echo " FAIL: $_name"
echo " $_why"
echo " --- verify-build output ---"
printf '%s\n' "$_out" | sed 's/^/ /'
echo " --- end output ---"
}
# --- cases ------------------------------------------------------------------
#
# Each runs with the fixture as its working directory.
c_control() { :; }
c_trailing_space() {
cp dist/chrome/src/popup/index.js "dist/chrome/src/popup/index.js "
}
c_embedded_newline() {
cp dist/chrome/src/popup/index.js "dist/chrome/src/popup/index.js$NEWLINE"
}
c_dist_symlink() {
mv dist dist.real
ln -s dist.real dist
}
c_unwalkable_subtree() { chmod 000 dist/chrome/src/content; }
c_dangling_symlink() {
ln -s /nonexistent-target-for-test-verify-build dist/chrome/dangling.js
}
c_dir_symlink() { ln -s src dist/chrome/link-to-dir; }
c_alias_symlink() { ln -s popup/index.js dist/chrome/src/aliased.js; }
c_manifest_missing() { rm dist/constants-bundles.txt; }
c_manifest_empty() { : >dist/constants-bundles.txt; }
c_manifest_unreadable() { chmod 000 dist/constants-bundles.txt; }
c_bundle_missing() { rm dist/chrome/src/popup/index.js; }
c_bundle_empty() { : >dist/chrome/src/popup/index.js; }
c_bundle_unreadable() { chmod 000 dist/chrome/src/popup/index.js; }
c_unlisted_extension() {
cp dist/chrome/src/popup/index.js dist/chrome/src/popup/extra.mjs
}
c_no_marker() { printf 'var d=4;\n' >dist/chrome/src/popup/index.js; }
c_both_markers() {
printf '/* %s */\n' "$MARKER_ON" >>dist/chrome/src/popup/index.js
}
run_cases() {
check_case "control: untouched dist passes" \
no release 0 "2 bundle(s) verified $MARKER_OFF" c_control
check_case "unlisted marker-carrying file, trailing space in name" \
no release 1 "carries a debug marker but is absent from" \
c_trailing_space
check_case "unlisted marker-carrying file, newline in name" \
no release 1 "carries a debug marker but is absent from" \
c_embedded_newline
check_case "dist/ replaced by a symlink" \
no release 1 "dist is a symlink, not a directory." c_dist_symlink
check_case "unwalkable subtree under dist/" \
yes release 1 "enumerating dist/, so part of the tree" \
c_unwalkable_subtree
check_case "dangling symlink under dist/" \
no release 1 \
"reading dist/chrome/dangling.js, so the file could not be" \
c_dangling_symlink
check_case "symlink to a directory under dist/" \
no release 1 \
"reading dist/chrome/link-to-dir, so the file could not be" \
c_dir_symlink
check_case "symlink to a listed bundle under an unlisted path" \
no release 1 \
"dist/chrome/src/aliased.js carries a debug marker but is absent" \
c_alias_symlink
check_case "manifest missing" \
no release 1 "dist/constants-bundles.txt is missing." \
c_manifest_missing
check_case "manifest empty" \
no release 1 "is empty, so no emitted bundle was found to contain" \
c_manifest_empty
check_case "manifest unreadable" \
yes release 1 "is not readable, so nothing was inspected." \
c_manifest_unreadable
check_case "listed bundle missing" \
no release 1 \
"lists dist/chrome/src/popup/index.js, which does not exist." \
c_bundle_missing
check_case "listed bundle empty" \
no release 1 "which is empty. An empty bundle" c_bundle_empty
check_case "listed bundle unreadable" \
yes release 1 \
"reading dist/chrome/src/popup/index.js, so the file could not be" \
c_bundle_unreadable
check_case "unlisted extension carrying a marker" \
no release 1 \
"dist/chrome/src/popup/extra.mjs carries a debug marker but is" \
c_unlisted_extension
check_case "listed bundle carries no marker" \
no release 1 "carries no debug marker, so its DEBUG state cannot be" \
c_no_marker
check_case "listed bundle carries both markers" \
no release 1 "carries both debug markers, so DEBUG was not resolved" \
c_both_markers
check_case "wrong marker for the requested mode" \
no debug 1 "is $MARKER_OFF but this build expects $MARKER_ON" \
c_control
}
# --- main --------------------------------------------------------------------
main() {
cd "$ROOT"
[ -x "$VERIFY_BUILD" ] || {
echo "test-verify-build: $VERIFY_BUILD is missing or not executable" >&2
exit 1
}
echo "Testing script/verify-build failure modes..."
probe_permission_runner
if [ "$PERM_ENABLED" = yes ]; then
echo " permission cases: enabled (runner: $PERM_HOW, proved against" \
"a mode-000 file)"
fi
run_cases
if [ "$FAILED" -ne 0 ]; then
echo "test-verify-build: $FAILED case(s) FAILED," \
"$PASSED passed, $SKIPPED skipped" >&2
exit 1
fi
if [ "$SKIPPED" -ne 0 ]; then
cat <<EOF
################################################################################
## WARNING: $SKIPPED PERMISSION CASE(S) DID NOT RUN, AND THIS RUN DOES NOT
## PROVE THEM. This process is uid $(id -u), and no runner subject to file
## permissions was available. Tried: $PERM_HOW.
## Under root, chmod 000 stops neither find nor grep, so these cases would
## have passed without testing anything. They were skipped, not counted:
$SKIPPED_NAMES################################################################################
EOF
echo "test-verify-build: $PASSED case(s) passed," \
"$SKIPPED SKIPPED AND NOT PROVEN (see the warning above)"
return 0
fi
echo "test-verify-build: $PASSED case(s) passed"
}
main "$@"

View File

@@ -13,19 +13,7 @@ const {
} = require("../shared/state"); } = require("../shared/state");
const { refreshBalances, getProvider } = require("../shared/balances"); const { refreshBalances, getProvider } = require("../shared/balances");
const { debugFetch, log } = require("../shared/log"); const { debugFetch, log } = require("../shared/log");
const { const { verifySignedTx, verifySignature } = require("../shared/approvalVerify");
verifySignedTx,
verifySignature,
failureIsRetryable,
describeTxFailure,
sameAddress,
ApprovalMismatchError,
TX_STAGE_SIGN,
TX_STAGE_VERIFY,
TX_STAGE_BROADCAST,
TX_STAGE_INFLIGHT,
} = require("../shared/approvalVerify");
const { prepareApprovalTx } = require("../shared/approvalTx");
const { const {
isPhishingDomain, isPhishingDomain,
refreshPhishingListOnSchedule, refreshPhishingListOnSchedule,
@@ -39,21 +27,17 @@ const {
registerAlarmHandlers, registerAlarmHandlers,
} = require("../shared/alarms"); } = require("../shared/alarms");
const { const storageApi =
actionApi, typeof browser !== "undefined"
runtimeApi, ? browser.storage.local
storageGet, : chrome.storage.local;
tabsQuery, const runtime =
tabsSendMessage, typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
windowsApi, const windowsApi =
windowsCreate, typeof browser !== "undefined" ? browser.windows : chrome.windows;
windowsGetLastFocused, const tabsApi = typeof browser !== "undefined" ? browser.tabs : chrome.tabs;
windowsRemove, const actionApi =
} = require("../shared/browserApi"); typeof browser !== "undefined" ? browser.browserAction : chrome.action;
const runtime = runtimeApi();
const windowsNs = windowsApi();
const actionNs = actionApi();
// Connected sites (in-memory, non-persisted): { "origin:address": true } // Connected sites (in-memory, non-persisted): { "origin:address": true }
const connectedSites = {}; const connectedSites = {};
@@ -62,7 +46,7 @@ const connectedSites = {};
const pendingApprovals = {}; const pendingApprovals = {};
async function getState() { async function getState() {
const result = await storageGet("autistmask"); const result = await storageApi.get("autistmask");
return ( return (
result.autistmask || { result.autistmask || {
wallets: [], wallets: [],
@@ -84,14 +68,6 @@ async function getActiveAddress() {
return null; return null;
} }
// Whether a request names a signing address other than the active one. Such a
// request is refused rather than quietly signed as whichever address happens
// to be active: the page asked for account A and would otherwise be handed
// something from account B.
function namesAnotherAddress(requested, activeAddress) {
return !!requested && !sameAddress(requested, activeAddress);
}
async function getRpcUrl() { async function getRpcUrl() {
const s = await getState(); const s = await getState();
return s.rpcUrl || DEFAULT_RPC_URL; return s.rpcUrl || DEFAULT_RPC_URL;
@@ -126,78 +102,20 @@ async function proxyRpc(method, params) {
} }
function resetPopupUrl() { function resetPopupUrl() {
if (actionNs && typeof actionNs.setPopup === "function") { if (actionApi && typeof actionApi.setPopup === "function") {
actionNs.setPopup({ popup: "src/popup/index.html" }); actionApi.setPopup({ popup: "src/popup/index.html" });
} }
} }
// Settle a pending approval: hand `result` to the promise the requesting page
// is waiting on and retire the approval. This is the ONLY place an approval is
// resolved or removed — the popup closing, an active-address switch, a reject
// from the popup and the attempt that signs and broadcasts all come through
// here — because a settlement that bypasses the claim below is a fund-loss bug
// and enumerating the call sites has repeatedly missed one.
//
// A claimed approval belongs to the attempt holding the claim, and only that
// attempt may settle it. Anything else settling first would leave the attempt
// running to completion against an already-settled promise: the transaction
// reaches the chain while the page is told "User rejected the request", and the
// user's natural response is to send it again at a fresh nonce.
//
// Returns false when the approval is gone or claimed by someone else, so the
// caller can refuse instead of assuming it settled.
function settleApproval(id, result, options) {
const approval = pendingApprovals[id];
if (!approval) return false;
const holdsClaim = !!(options && options.holdsClaim);
if (approval.attemptInFlight && !holdsClaim) return false;
delete pendingApprovals[id];
approval.resolve(result);
resetPopupUrl();
return true;
}
// Take exclusive hold of a pending approval for one attempt, or refuse.
//
// An approval that failed retryably has to stay in pendingApprovals, so its
// presence cannot be the interlock against a second attempt; this flag is. It
// is set synchronously, before the handler's first await, so a second response
// carrying the same id — a reloaded approval window re-rendering a live
// Approve button, a popup that emits the message twice — finds the attempt
// already running instead of starting an independent verify and broadcast.
// Without it one approval can put two transactions on the chain: with the
// ordinary dApp approval shape the page fixes no nonce, so two artifacts
// signed at different nonces both verify.
function claimApproval(approval) {
if (approval.attemptInFlight) return false;
approval.attemptInFlight = true;
return true;
}
// Release an approval whose attempt failed in a way the user can retry.
// Nothing was broadcast, so the next attempt may claim it.
function releaseApproval(approval) {
approval.attemptInFlight = false;
}
// Open approval in a separate popup window. // Open approval in a separate popup window.
// This is the primary mechanism for tx/sign approvals (triggered programmatically, // This is the primary mechanism for tx/sign approvals (triggered programmatically,
// not from a user gesture) and the fallback for site-connection approvals. // not from a user gesture) and the fallback for site-connection approvals.
// Never rejects. Its callers raise it from inside a Promise executor and drop function openApprovalWindow(id) {
// the result on the floor, so a rejection here would be unhandled.
async function openApprovalWindow(id) {
const popupUrl = runtime.getURL("src/popup/index.html?approval=" + id); const popupUrl = runtime.getURL("src/popup/index.html?approval=" + id);
const popupWidth = 360; const popupWidth = 360;
const popupHeight = 600; const popupHeight = 600;
let currentWin = null; windowsApi.getLastFocused((currentWin) => {
try {
currentWin = await windowsGetLastFocused();
} catch {
// Nothing focused to centre on. The window still opens, at whatever
// position the browser picks.
}
const opts = { const opts = {
url: popupUrl, url: popupUrl,
type: "popup", type: "popup",
@@ -212,26 +130,12 @@ async function openApprovalWindow(id) {
currentWin.top + (currentWin.height - popupHeight) / 2, currentWin.top + (currentWin.height - popupHeight) / 2,
); );
} }
windowsApi.create(opts, (win) => {
let win = null; if (win) {
try {
win = await windowsCreate(opts);
} catch (e) {
// No window means no approval screen and no way for the user to
// answer. The request stays pending rather than being settled behind
// their back; say so rather than failing silently.
log.errorf("could not open the approval window:", e);
return;
}
// The id the onRemoved listener matches on to turn a closed window into a
// rejection. Guarded because the create() above is a real await now: an
// address switch can settle and remove the approval while the window is
// opening, and writing the id back would resurrect a bare entry that
// nothing would ever resolve.
if (win && pendingApprovals[id]) {
pendingApprovals[id].windowId = win.id; pendingApprovals[id].windowId = win.id;
} }
});
});
} }
// Open an approval popup and return a promise that resolves with the user decision. // Open an approval popup and return a promise that resolves with the user decision.
@@ -241,12 +145,12 @@ function requestApproval(origin, hostname) {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
pendingApprovals[id] = { origin, hostname, resolve }; pendingApprovals[id] = { origin, hostname, resolve };
if (actionNs && typeof actionNs.openPopup === "function") { if (actionApi && typeof actionApi.openPopup === "function") {
actionNs.setPopup({ actionApi.setPopup({
popup: "src/popup/index.html?approval=" + id, popup: "src/popup/index.html?approval=" + id,
}); });
try { try {
const result = actionNs.openPopup(); const result = actionApi.openPopup();
if (result && typeof result.catch === "function") { if (result && typeof result.catch === "function") {
result.catch(() => openApprovalWindow(id)); result.catch(() => openApprovalWindow(id));
} }
@@ -263,21 +167,13 @@ function requestApproval(origin, hostname) {
// Uses windows.create() directly because tx approvals are triggered programmatically // Uses windows.create() directly because tx approvals are triggered programmatically
// (from a dApp RPC call), not from a user gesture, so action.openPopup() is // (from a dApp RPC call), not from a user gesture, so action.openPopup() is
// unreliable in this context. // unreliable in this context.
// function requestTxApproval(origin, hostname, txParams) {
// `approvedTx` is the fully populated transaction (see approvalTx.js): the
// object the popup displays, the object it signs, and the object the artifact
// is verified against. `approvedFrom` is the address that is active now, and
// it is pinned here rather than read again at signing time — an address switch
// between approval and signing must refuse, not sign from an account this
// screen never named.
function requestTxApproval(origin, hostname, approvedTx, approvedFrom) {
return new Promise((resolve) => { return new Promise((resolve) => {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
pendingApprovals[id] = { pendingApprovals[id] = {
origin, origin,
hostname, hostname,
approvedTx, txParams,
approvedFrom,
resolve, resolve,
type: "tx", type: "tx",
}; };
@@ -290,14 +186,13 @@ function requestTxApproval(origin, hostname, approvedTx, approvedFrom) {
// Uses windows.create() directly because sign approvals are triggered programmatically // Uses windows.create() directly because sign approvals are triggered programmatically
// (from a dApp RPC call), not from a user gesture, so action.openPopup() is // (from a dApp RPC call), not from a user gesture, so action.openPopup() is
// unreliable in this context. // unreliable in this context.
function requestSignApproval(origin, hostname, signParams, approvedFrom) { function requestSignApproval(origin, hostname, signParams) {
return new Promise((resolve) => { return new Promise((resolve) => {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
pendingApprovals[id] = { pendingApprovals[id] = {
origin, origin,
hostname, hostname,
signParams, signParams,
approvedFrom,
resolve, resolve,
type: "sign", type: "sign",
}; };
@@ -308,7 +203,7 @@ function requestSignApproval(origin, hostname, signParams, approvedFrom) {
// Detect when an approval popup (browser-action) closes without a response. // Detect when an approval popup (browser-action) closes without a response.
// TX and sign approvals now use windows.create() and are handled by the // TX and sign approvals now use windows.create() and are handled by the
// windows.onRemoved listener below, but we still handle site-connection // windowsApi.onRemoved listener below, but we still handle site-connection
// approval disconnects here. // approval disconnects here.
runtime.onConnect.addListener((port) => { runtime.onConnect.addListener((port) => {
if (port.name.startsWith("approval:")) { if (port.name.startsWith("approval:")) {
@@ -320,7 +215,8 @@ runtime.onConnect.addListener((port) => {
// Keep pending — user can reopen the toolbar popup // Keep pending — user can reopen the toolbar popup
return; return;
} }
settleApproval(id, { approved: false, remember: false }); approval.resolve({ approved: false, remember: false });
delete pendingApprovals[id];
} }
resetPopupUrl(); resetPopupUrl();
}); });
@@ -549,16 +445,6 @@ async function handleRpc(method, params, origin) {
? { method, message: params[0], from: params[1] } ? { method, message: params[0], from: params[1] }
: { method, message: params[1], from: params[0] }; : { method, message: params[1], from: params[0] };
if (namesAnotherAddress(signParams.from, activeAddress)) {
return {
error: {
code: 4100,
message:
"This site asked to sign as an address that is not the active one.",
},
};
}
if (method === "eth_sign") { if (method === "eth_sign") {
signParams.dangerWarning = signParams.dangerWarning =
"\u26a0\ufe0f DANGER: This site is requesting to sign a raw hash. " + "\u26a0\ufe0f DANGER: This site is requesting to sign a raw hash. " +
@@ -570,7 +456,6 @@ async function handleRpc(method, params, origin) {
origin, origin,
hostname, hostname,
signParams, signParams,
activeAddress,
); );
if (decision.error) return { error: decision.error }; if (decision.error) return { error: decision.error };
return { result: decision.signature }; return { result: decision.signature };
@@ -592,20 +477,10 @@ async function handleRpc(method, params, origin) {
} }
const signParams = { method, typedData: params[1], from: params[0] }; const signParams = { method, typedData: params[1], from: params[0] };
if (namesAnotherAddress(signParams.from, activeAddress)) {
return {
error: {
code: 4100,
message:
"This site asked to sign as an address that is not the active one.",
},
};
}
const decision = await requestSignApproval( const decision = await requestSignApproval(
origin, origin,
hostname, hostname,
signParams, signParams,
activeAddress,
); );
if (decision.error) return { error: decision.error }; if (decision.error) return { error: decision.error };
return { result: decision.signature }; return { result: decision.signature };
@@ -627,51 +502,7 @@ async function handleRpc(method, params, origin) {
} }
const txParams = params?.[0] || {}; const txParams = params?.[0] || {};
if (namesAnotherAddress(txParams.from, activeAddress)) { const decision = await requestTxApproval(origin, hostname, txParams);
return {
error: {
code: 4100,
message:
"This site asked to send from an address that is not the active one.",
},
};
}
// Populate here, before any window opens, so that the transaction the
// user is shown is a complete one and is the same object the signed
// artifact is checked against. A failure raises no approval at all and
// is reported to the requesting page; see approvalTx.js.
let approvedTx;
try {
approvedTx = await prepareApprovalTx(
getProvider(await getRpcUrl()),
activeAddress,
txParams,
);
} catch (e) {
return { error: { message: e.message } };
}
// Population is a network round trip, and the user can switch address
// during it. Raising the approval anyway would put an account on the
// screen that the wallet is no longer on, and it could never be signed
// — the signing handler refuses exactly that. Refuse it here instead,
// while the page is still waiting and nothing has been displayed.
if (!sameAddress(await getActiveAddress(), activeAddress)) {
return {
error: {
message:
"The active address changed while this transaction was being prepared, so it was not sent.",
},
};
}
const decision = await requestTxApproval(
origin,
hostname,
approvedTx,
activeAddress,
);
if (decision.error) return { error: decision.error }; if (decision.error) return { error: decision.error };
return { result: decision.txHash }; return { result: decision.txHash };
} }
@@ -690,26 +521,24 @@ async function handleRpc(method, params, origin) {
} }
// Broadcast chainChanged to all tabs when the network is switched. // Broadcast chainChanged to all tabs when the network is switched.
// function broadcastChainChanged(chainId) {
// Never rejects: its caller is an RPC handler that must answer the page tabsApi.query({}, (tabs) => {
// whatever the browser made of the broadcast.
async function broadcastChainChanged(chainId) {
let tabs;
try {
tabs = await tabsQuery({});
} catch {
return;
}
for (const tab of tabs) { for (const tab of tabs) {
// A tab with no content script has no receiver, and that is the tabsApi.sendMessage(
// ordinary case rather than a fault. The rejection it produces is the tab.id,
// promise-shaped form of the runtime.lastError this used to read. {
tabsSendMessage(tab.id, {
type: "AUTISTMASK_EVENT", type: "AUTISTMASK_EVENT",
eventName: "chainChanged", eventName: "chainChanged",
data: chainId, data: chainId,
}).catch(() => {}); },
() => {
if (runtime.lastError) {
// expected for tabs without our content script
} }
},
);
}
});
} }
// Broadcast accountsChanged to all tabs, respecting per-address permissions // Broadcast accountsChanged to all tabs, respecting per-address permissions
@@ -718,37 +547,29 @@ async function broadcastAccountsChanged() {
for (const key of Object.keys(connectedSites)) { for (const key of Object.keys(connectedSites)) {
delete connectedSites[key]; delete connectedSites[key];
} }
// Reject and close any pending approval popups so they don't hang. An // Reject and close any pending approval popups so they don't hang
// approval an attempt has already claimed is left alone entirely: it is
// being signed and broadcast right now, and neither rejecting it to the
// page nor closing the window it is reporting into is survivable.
for (const [id, approval] of Object.entries(pendingApprovals)) { for (const [id, approval] of Object.entries(pendingApprovals)) {
const rejection = if (approval.type === "tx" || approval.type === "sign") {
approval.type === "tx" || approval.type === "sign" approval.resolve({
? { error: { code: 4001, message: "User rejected the request." },
error: { });
code: 4001, } else {
message: "User rejected the request.", approval.resolve({ approved: false, remember: false });
},
} }
: { approved: false, remember: false };
if (!settleApproval(id, rejection)) continue;
if (approval.windowId) { if (approval.windowId) {
// Rejects when the window has already gone, which is a race the windowsApi.remove(approval.windowId, () => {
// user wins routinely by closing it themselves. if (runtime.lastError) {
windowsRemove(approval.windowId).catch(() => {}); // window already closed
} }
});
}
delete pendingApprovals[id];
} }
resetPopupUrl(); resetPopupUrl();
const s = await getState(); const s = await getState();
const activeAddress = await getActiveAddress(); const activeAddress = await getActiveAddress();
const allowed = activeAddress ? s.allowedSites[activeAddress] || [] : []; const allowed = activeAddress ? s.allowedSites[activeAddress] || [] : [];
let tabs; tabsApi.query({}, (tabs) => {
try {
tabs = await tabsQuery({});
} catch {
return;
}
for (const tab of tabs) { for (const tab of tabs) {
const origin = tab.url ? new URL(tab.url).origin : ""; const origin = tab.url ? new URL(tab.url).origin : "";
const hostname = extractHostname(origin); const hostname = extractHostname(origin);
@@ -756,14 +577,22 @@ async function broadcastAccountsChanged() {
activeAddress && activeAddress &&
(allowed.includes(hostname) || (allowed.includes(hostname) ||
connectedSites[origin + ":" + activeAddress]); connectedSites[origin + ":" + activeAddress]);
// Same as chainChanged above: a tab without our content script tabsApi.sendMessage(
// rejects, and that is expected rather than a fault. tab.id,
tabsSendMessage(tab.id, { {
type: "AUTISTMASK_EVENT", type: "AUTISTMASK_EVENT",
eventName: "accountsChanged", eventName: "accountsChanged",
data: hasPermission ? [activeAddress] : [], data: hasPermission ? [activeAddress] : [],
}).catch(() => {}); },
() => {
// Ignore errors for tabs without content script
if (runtime.lastError) {
// expected for tabs without our content script
} }
},
);
}
});
} }
// Background balance refresh: every 60 seconds when the popup isn't open. // Background balance refresh: every 60 seconds when the popup isn't open.
@@ -850,26 +679,23 @@ if (runtime.onStartup) {
} }
startBackgroundJobs(); startBackgroundJobs();
// When approval window is closed without a response, treat as rejection. // When approval window is closed without a response, treat as rejection
// "Without a response" is the operative part: the popup stays open across the if (windowsApi && windowsApi.onRemoved) {
// verify and broadcast it is waiting on, so a user closing an apparently-hung windowsApi.onRemoved.addListener((windowId) => {
// window is an ordinary event with an attempt already in flight behind it.
// settleApproval() refuses those, which leaves the attempt to report its real
// outcome to the page.
if (windowsNs && windowsNs.onRemoved) {
windowsNs.onRemoved.addListener((windowId) => {
for (const [id, approval] of Object.entries(pendingApprovals)) { for (const [id, approval] of Object.entries(pendingApprovals)) {
if (approval.windowId !== windowId) continue; if (approval.windowId === windowId) {
const rejection = if (approval.type === "tx" || approval.type === "sign") {
approval.type === "tx" || approval.type === "sign" approval.resolve({
? {
error: { error: {
code: 4001, code: 4001,
message: "User rejected the request.", message: "User rejected the request.",
}, },
});
} else {
approval.resolve({ approved: false, remember: false });
}
delete pendingApprovals[id];
} }
: { approved: false, remember: false };
settleApproval(id, rejection);
} }
}); });
} }
@@ -919,16 +745,11 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
}; };
if (approval.type === "tx") { if (approval.type === "tx") {
resp.type = "tx"; resp.type = "tx";
// The populated transaction, and the address it was raised resp.txParams = approval.txParams;
// for. The popup displays and signs exactly this and does not
// populate or re-read anything itself.
resp.approvedTx = approval.approvedTx;
resp.approvedFrom = approval.approvedFrom;
} }
if (approval.type === "sign") { if (approval.type === "sign") {
resp.type = "sign"; resp.type = "sign";
resp.signParams = approval.signParams; resp.signParams = approval.signParams;
resp.approvedFrom = approval.approvedFrom;
} }
// Flag if the requesting domain is on the phishing blocklist. // Flag if the requesting domain is on the phishing blocklist.
resp.isPhishingDomain = isPhishingDomain(approval.hostname); resp.isPhishingDomain = isPhishingDomain(approval.hostname);
@@ -940,10 +761,14 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
} }
if (msg.type === "AUTISTMASK_APPROVAL_RESPONSE") { if (msg.type === "AUTISTMASK_APPROVAL_RESPONSE") {
settleApproval(msg.id, { const approval = pendingApprovals[msg.id];
if (approval) {
approval.resolve({
approved: msg.approved, approved: msg.approved,
remember: msg.remember, remember: msg.remember,
}); });
delete pendingApprovals[msg.id];
}
resetPopupUrl(); resetPopupUrl();
return false; return false;
} }
@@ -951,50 +776,21 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === "AUTISTMASK_TX_RESPONSE") { if (msg.type === "AUTISTMASK_TX_RESPONSE") {
const approval = pendingApprovals[msg.id]; const approval = pendingApprovals[msg.id];
if (!approval) return false; if (!approval) return false;
delete pendingApprovals[msg.id];
resetPopupUrl();
// A reject arriving while an attempt holds the approval is refused,
// not honoured: the attempt is on its way to broadcasting the
// transaction, and resolving 4001 here would tell the page the request
// was rejected while it goes out.
if (!msg.approved) { if (!msg.approved) {
if ( approval.resolve({
!settleApproval(msg.id, { error: { code: 4001, message: "User rejected the request." },
error: {
code: 4001,
message: "User rejected the request.",
},
})
) {
sendResponse({
error: "This transaction is already being sent.",
retryable: false,
stage: TX_STAGE_BROADCAST,
}); });
return false;
}
return true; return true;
} }
// The popup signs; it reports back here when it could not. Keep the // The popup signs; it reports back here when it could not. Fail the
// approval so the user can correct the problem and try again with the // request the same way this handler used to when it did the signing.
// transaction they already saw.
if (msg.error) { if (msg.error) {
const outcome = describeTxFailure(TX_STAGE_SIGN, msg.error); approval.resolve({ error: { message: msg.error } });
sendResponse({ sendResponse({ error: msg.error });
error: outcome.error,
retryable: outcome.retryable,
stage: TX_STAGE_SIGN,
});
return false;
}
// Exactly one broadcast per approval, whatever the popup sends.
if (!claimApproval(approval)) {
sendResponse({
error: "This transaction is already being sent.",
retryable: false,
stage: TX_STAGE_BROADCAST,
});
return false; return false;
} }
@@ -1002,78 +798,24 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
try { try {
await loadState(); await loadState();
const activeAddress = await getActiveAddress(); const activeAddress = await getActiveAddress();
// An address switch between approval and signing refuses. The
// approval named one account; signing from whichever account
// is active now would send funds from an account this screen
// never showed. A switch normally rejects every pending
// approval on its way through broadcastAccountsChanged(), so
// this is the case where that did not reach the approval —
// and it is a refusal, not a retry, because the transaction
// the user saw is no longer the transaction that would go out.
if (!sameAddress(activeAddress, approval.approvedFrom)) {
throw new ApprovalMismatchError(
"The active address changed after this transaction was approved, so it was not sent.",
);
}
// The popup holds the secret, but the background stays the // The popup holds the secret, but the background stays the
// authority on what is broadcast: the raw transaction must be // authority on what is broadcast: the raw transaction must be
// the transaction that was displayed, signed by the address // the approved one, signed by the approved address.
// the approval named, on the network that is selected.
verifySignedTx( verifySignedTx(
msg.rawSignedTx, msg.rawSignedTx,
approval.approvedTx, approval.txParams,
approval.approvedFrom, activeAddress,
currentNetwork().chainId,
); );
} catch (e) {
// A signed transaction that is not the approved one is not
// retried against that approval; it is refused outright.
// Anything else that failed before the check ran is the
// user's to retry.
const outcome = describeTxFailure(TX_STAGE_VERIFY, e);
if (outcome.spendApproval) {
settleApproval(
msg.id,
{ error: { message: outcome.error } },
{ holdsClaim: true },
);
} else {
releaseApproval(approval);
}
sendResponse({
error: outcome.error,
retryable: outcome.retryable,
stage: TX_STAGE_VERIFY,
});
return;
}
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);
settleApproval( approval.resolve({ txHash: tx.hash });
msg.id,
{ txHash: tx.hash },
{ holdsClaim: true },
);
sendResponse({ txHash: tx.hash }); sendResponse({ txHash: tx.hash });
} catch (e) { } catch (e) {
// Terminal, never retried: the node may have accepted the const errMsg = e.shortMessage || e.message;
// transaction and still failed to answer, so the wallet cannot approval.resolve({
// tell a transaction that never left from one already in the error: { message: errMsg },
// mempool. The page has been given its outcome for this
// request; a second attempt would report a second one.
const outcome = describeTxFailure(TX_STAGE_BROADCAST, e);
settleApproval(
msg.id,
{ error: { message: outcome.error } },
{ holdsClaim: true },
);
sendResponse({
error: outcome.error,
retryable: outcome.retryable,
stage: TX_STAGE_BROADCAST,
}); });
sendResponse({ error: errMsg });
} }
})(); })();
return true; return true;
@@ -1082,82 +824,41 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === "AUTISTMASK_SIGN_RESPONSE") { if (msg.type === "AUTISTMASK_SIGN_RESPONSE") {
const approval = pendingApprovals[msg.id]; const approval = pendingApprovals[msg.id];
if (!approval) return false; if (!approval) return false;
delete pendingApprovals[msg.id];
resetPopupUrl();
// Same as the transaction path: a reject cannot retire an approval an
// attempt already holds.
if (!msg.approved) { if (!msg.approved) {
if ( approval.resolve({
!settleApproval(msg.id, { error: { code: 4001, message: "User rejected the request." },
error: {
code: 4001,
message: "User rejected the request.",
},
})
) {
sendResponse({
error: "This request is already being signed.",
retryable: false,
stage: TX_STAGE_INFLIGHT,
}); });
return false;
}
return true; return true;
} }
// The popup signs; it reports back here when it could not. Keep the // The popup signs; it reports back here when it could not. Fail the
// approval so the user can correct the problem and try again with the // request the same way this handler used to when it did the signing.
// message they already saw.
if (msg.error) { if (msg.error) {
sendResponse({ error: msg.error, retryable: true }); approval.resolve({ error: { message: msg.error } });
return false; sendResponse({ error: msg.error });
}
// Exactly one signature handed back per approval.
if (!claimApproval(approval)) {
sendResponse({
error: "This request is already being signed.",
retryable: false,
stage: TX_STAGE_INFLIGHT,
});
return false; return false;
} }
(async () => { (async () => {
try { try {
const activeAddress = await getActiveAddress(); const activeAddress = await getActiveAddress();
// Same as the transaction path: the address the approval named
// is the one that must have signed, and a switch since then is
// a refusal rather than a signature from another account.
if (!sameAddress(activeAddress, approval.approvedFrom)) {
throw new ApprovalMismatchError(
"The active address changed after this request was approved, so it was not signed.",
);
}
// The popup holds the secret, but the background stays the // The popup holds the secret, but the background stays the
// authority on what is handed back to the page: the signature // authority on what is handed back to the page: the signature
// must cover the approved payload and recover to the address // must cover the approved payload and recover to the approved
// the approval named. // address.
const signature = msg.signature; const signature = msg.signature;
verifySignature( verifySignature(approval.signParams, signature, activeAddress);
approval.signParams, approval.resolve({ signature });
signature,
approval.approvedFrom,
);
settleApproval(msg.id, { signature }, { holdsClaim: true });
sendResponse({ signature }); sendResponse({ signature });
} catch (e) { } catch (e) {
const errMsg = e.shortMessage || e.message; const errMsg = e.shortMessage || e.message;
const retryable = failureIsRetryable(e); approval.resolve({
if (!retryable) { error: { message: errMsg },
settleApproval( });
msg.id, sendResponse({ error: errMsg });
{ error: { message: errMsg } },
{ holdsClaim: true },
);
} else {
releaseApproval(approval);
}
sendResponse({ error: errMsg, retryable });
} }
})(); })();
return true; return true;

View File

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

View File

@@ -11,39 +11,6 @@
let nextId = 1; let nextId = 1;
const pending = {}; const pending = {};
// EIP-1193 ProviderRpcError: `code`, `message`, optional `data`. A class
// rather than properties bolted onto an Error because this object crosses
// no boundary after construction — it is built in the page's own realm and
// handed straight to the caller's catch — so the prototype survives and
// `error.name` is a stable thing for a dApp to see.
class ProviderRpcError extends Error {
constructor(code, message, data) {
super(message);
this.name = "ProviderRpcError";
this.code = code;
if (data !== undefined) this.data = data;
}
}
// Rebuild a boundary error as the error the page catches, carrying the
// code (and data) the extension reported. Without this a dApp cannot tell
// a user's refusal (4001) from a wallet that broke, and retries or shows
// an error instead of accepting the refusal.
//
// Whatever code arrived is passed through verbatim rather than being
// matched against a list: the extension emits 4001, 4100 and 4902 today,
// and a code this file has never heard of is still the truth about what
// happened. An error reported with no code at all stays a plain Error —
// a ProviderRpcError whose `code` is undefined would advertise a
// conformance it does not have. `message` is untouched in every case.
function toPageError(error) {
const message = (error && error.message) || "Request failed";
if (error && error.code !== undefined && error.code !== null) {
return new ProviderRpcError(error.code, message, error.data);
}
return new Error(message);
}
// Listen for responses from the content script // Listen for responses from the content script
window.addEventListener("message", function onUuid(event) { window.addEventListener("message", function onUuid(event) {
if (event.source !== window) return; if (event.source !== window) return;
@@ -53,7 +20,7 @@
if (!p) return; if (!p) return;
delete pending[id]; delete pending[id];
if (error) { if (error) {
p.reject(toPageError(error)); p.reject(new Error(error.message || "Request failed"));
} else { } else {
p.resolve(result); p.resolve(result);
} }

View File

@@ -1,42 +0,0 @@
// Parsing for the dust threshold field in Settings.
//
// Pure: no DOM, no state, so the accepted set can be unit tested directly
// instead of through the settings view.
//
// Accepted input is plain decimal digits only, meaning a whole number of
// gwei, zero or greater. Zero is a real setting: it hides nothing.
//
// Deliberately rejected, not coerced:
// "" nothing to save
// "-1" a negative threshold has no meaning
// "1.5" fractional gwei is not a threshold the filter can use
// "100 gwei" the unit is already printed beside the field
// "0x10" hex, which Number() would silently read as 16
// "1e3" exponent notation, which Number() would silently read as 1000
//
// The last two are the reason this is a digit test and not a Number() test.
// Number() accepts both, and accepting them would put a number in the field
// that the user did not type — the same silent substitution the visible
// rejection message exists to end.
// Must render on ONE line of #flash-msg, whose reserved height
// (min-h-[1.25rem]) is exactly one line at text-xs. A string long enough to
// wrap to two lines pushes the settings view down, which the No Layout Shift
// policy forbids. Do not lengthen this without re-running the layout test in
// tests/e2e/run.js, which measures the flash line and goes red on a shift.
const DUST_THRESHOLD_MESSAGE =
"Please enter a whole number of gwei, zero or greater.";
// Returns the threshold in gwei, or null if the input is not one.
function parseDustThresholdGwei(raw) {
if (typeof raw !== "string") return null;
const trimmed = raw.trim();
if (!/^[0-9]+$/.test(trimmed)) return null;
const val = Number(trimmed);
// A run of digits long enough to exceed Number's exact integer range
// would round on the way in, so it is not a threshold we can store.
if (!Number.isSafeInteger(val)) return null;
return val;
}
module.exports = { DUST_THRESHOLD_MESSAGE, parseDustThresholdGwei };

View File

@@ -1142,62 +1142,6 @@
</button> </button>
</div> </div>
<!-- ============ DELETE ADDRESS CONFIRM ============ -->
<div id="view-delete-address-confirm" class="view hidden">
<button
id="btn-delete-address-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
>
&lt; Back
</button>
<h2 class="font-bold mb-3">Remove Address</h2>
<p class="text-xs mb-2">
You are about to remove
<strong id="delete-address-label"></strong> from
<strong id="delete-address-wallet-name"></strong>.
</p>
<div
id="delete-address-value"
class="text-xs mb-2 break-all min-h-[1rem]"
></div>
<div
class="text-xs mb-2 border border-border border-dashed p-2"
>
This only stops this wallet from tracking the address.
Nothing is destroyed and no key is deleted. Any funds at the
address stay exactly where they are, and the address remains
yours. Any site permissions granted to this address are
forgotten.
</div>
<!-- Filled by src/popup/views/deleteAddress.js: the route
back names the wallet's own kind of key material. -->
<div
id="delete-address-recovery"
class="text-xs mb-2 border border-border border-dashed p-2"
></div>
<div
id="delete-address-balance"
class="text-xs mb-2 min-h-[1.25rem] pointer-events-none"
>
&nbsp;
</div>
<p class="text-xs text-muted mb-3">
A wallet always keeps at least one address. To remove the
last one, delete the whole wallet from Settings instead.
</p>
<div
id="delete-address-flash"
class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
style="visibility: hidden"
></div>
<button
id="btn-delete-address-confirm"
class="border border-border text-red-500 px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Remove Address
</button>
</div>
<!-- ============ SHOW RECOVERY PHRASE ============ --> <!-- ============ SHOW RECOVERY PHRASE ============ -->
<div id="view-show-phrase" class="view hidden"> <div id="view-show-phrase" class="view hidden">
<button <button
@@ -1496,33 +1440,6 @@
<div class="text-xs text-muted mb-1">Value</div> <div class="text-xs text-muted mb-1">Value</div>
<div id="approve-tx-value" class="text-xs font-bold"></div> <div id="approve-tx-value" class="text-xs font-bold"></div>
</div> </div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Network fee (max)</div>
<div
id="approve-tx-fee"
class="text-xs font-bold min-h-[1rem]"
></div>
<div
id="approve-tx-fee-detail"
class="text-xs text-muted min-h-[1rem]"
></div>
</div>
<div class="mb-3 flex justify-between">
<div>
<div class="text-xs text-muted mb-1">Network</div>
<div
id="approve-tx-network"
class="text-xs min-h-[1rem]"
></div>
</div>
<div>
<div class="text-xs text-muted mb-1">Nonce</div>
<div
id="approve-tx-nonce"
class="text-xs min-h-[1rem]"
></div>
</div>
</div>
<div id="approve-tx-data-section" class="mb-3 hidden"> <div id="approve-tx-data-section" class="mb-3 hidden">
<div class="text-xs text-muted mb-1">Raw data</div> <div class="text-xs text-muted mb-1">Raw data</div>
<div id="approve-tx-data" class="text-xs break-all"></div> <div id="approve-tx-data" class="text-xs break-all"></div>

View File

@@ -33,7 +33,6 @@ const receive = require("./views/receive");
const addToken = require("./views/addToken"); const addToken = require("./views/addToken");
const settings = require("./views/settings"); const settings = require("./views/settings");
const settingsAddToken = require("./views/settingsAddToken"); const settingsAddToken = require("./views/settingsAddToken");
const deleteAddress = require("./views/deleteAddress");
const approval = require("./views/approval"); const approval = require("./views/approval");
function renderWalletList() { function renderWalletList() {
@@ -102,10 +101,6 @@ const ctx = {
pushCurrentView(); pushCurrentView();
settingsAddToken.show(); settingsAddToken.show();
}, },
showDeleteAddress: (walletIdx, addrIdx) => {
pushCurrentView();
deleteAddress.show(walletIdx, addrIdx);
},
}; };
function needsAddress(view) { function needsAddress(view) {
@@ -261,7 +256,6 @@ async function init() {
addToken.init(ctx); addToken.init(ctx);
settings.init(ctx); settings.init(ctx);
settingsAddToken.init(ctx); settingsAddToken.init(ctx);
deleteAddress.init(ctx);
if (!state.hasWallet) { if (!state.hasWallet) {
showView("welcome"); showView("welcome");

View File

@@ -10,7 +10,6 @@ const {
onViewLeave, onViewLeave,
} = require("./helpers"); } = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { networkByChainId } = require("../../shared/networks");
const { const {
formatEther, formatEther,
formatUnits, formatUnits,
@@ -24,10 +23,11 @@ const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const { decryptWithPassword } = require("../../shared/vault"); const { decryptWithPassword } = require("../../shared/vault");
const { getSignerForAddress } = require("../../shared/wallet"); const { getSignerForAddress } = require("../../shared/wallet");
const { walletDefect } = require("../../shared/walletDefects"); const { walletDefect } = require("../../shared/walletDefects");
const { describeSigningFailure } = require("../../shared/approvalVerify"); const { getProvider } = require("../../shared/balances");
const txStatus = require("./txStatus"); const txStatus = require("./txStatus");
const uniswap = require("../../shared/uniswap"); const uniswap = require("../../shared/uniswap");
const { notify, runtimeApi, sendMessage } = require("../../shared/browserApi"); const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
const erc20Iface = new Interface(ERC20_ABI); const erc20Iface = new Interface(ERC20_ABI);
@@ -158,61 +158,21 @@ function showPhishingWarning(elementId, isPhishing) {
} }
} }
// The fields of the approved transaction the value and recipient lines do not
// already carry: network, gas limit, fee per gas, the most the fee can come to,
// and the nonce. The background compares every one of them against the signed
// artifact, so every one of them has to be on the screen — a number that is
// verified but never displayed is verified against nothing the user agreed to.
function showTxFee(approvedTx, ethPrice) {
const network = networkByChainId(approvedTx.chainId);
$("approve-tx-network").textContent = network
? network.name
: "Unknown network (chain id " + BigInt(approvedTx.chainId) + ")";
const gasLimit = BigInt(approvedTx.gasLimit);
const feePerGas = BigInt(approvedTx.maxFeePerGas || approvedTx.gasPrice);
const maxFeeEth = formatTxValue(formatEther(gasLimit * feePerGas));
const usdStr = formatUsd(
ethPrice ? parseFloat(maxFeeEth) * ethPrice : null,
);
$("approve-tx-fee").textContent =
maxFeeEth + " ETH" + (usdStr ? " (" + usdStr + ")" : "");
let detail =
gasLimit.toString() +
" gas at up to " +
formatUnits(feePerGas, 9) +
" gwei";
if (approvedTx.maxPriorityFeePerGas) {
detail +=
", " +
formatUnits(approvedTx.maxPriorityFeePerGas, 9) +
" gwei priority";
}
$("approve-tx-fee-detail").textContent = detail;
$("approve-tx-nonce").textContent = BigInt(approvedTx.nonce).toString();
}
function showTxApproval(details) { function showTxApproval(details) {
showPhishingWarning( showPhishingWarning(
"approve-tx-phishing-warning", "approve-tx-phishing-warning",
details.isPhishingDomain, details.isPhishingDomain,
); );
// The transaction the background populated. It is displayed as it stands, pendingTxParams = details.txParams;
// signed as it stands, and verified against as it stands — the popup fills
// nothing in, so there is no number on this screen that the background
// cannot compare with the artifact it gets back.
pendingTxParams = details.approvedTx;
const approvedTx = details.approvedTx;
const toAddr = approvedTx.to; const toAddr = details.txParams.to;
const token = toAddr ? TOKEN_BY_ADDRESS.get(toAddr.toLowerCase()) : null; const token = toAddr ? TOKEN_BY_ADDRESS.get(toAddr.toLowerCase()) : null;
const ethValue = formatEther(approvedTx.value || "0"); const ethValue = formatEther(details.txParams.value || "0");
// Build txInfo for status screens // Build txInfo for status screens
pendingTxDetails = { pendingTxDetails = {
from: details.approvedFrom, from: state.activeAddress,
to: toAddr || "", to: toAddr || "",
amount: formatTxValue(ethValue), amount: formatTxValue(ethValue),
token: "ETH", token: "ETH",
@@ -220,7 +180,7 @@ function showTxApproval(details) {
}; };
// If this is an ERC-20 call, try to extract the real recipient and amount // If this is an ERC-20 call, try to extract the real recipient and amount
const decoded = decodeCalldata(approvedTx.data, toAddr || ""); const decoded = decodeCalldata(details.txParams.data, toAddr || "");
if (decoded && decoded.details) { if (decoded && decoded.details) {
let decodedTokenAddr = null; let decodedTokenAddr = null;
let decodedTokenSymbol = null; let decodedTokenSymbol = null;
@@ -258,7 +218,7 @@ function showTxApproval(details) {
} }
$("approve-tx-hostname").textContent = details.hostname; $("approve-tx-hostname").textContent = details.hostname;
$("approve-tx-from").innerHTML = approvalAddressHtml(details.approvedFrom); $("approve-tx-from").innerHTML = approvalAddressHtml(state.activeAddress);
// Show token symbol next to contract address if known // Show token symbol next to contract address if known
const symbol = toAddr ? tokenLabel(toAddr) : null; const symbol = toAddr ? tokenLabel(toAddr) : null;
@@ -274,7 +234,7 @@ function showTxApproval(details) {
} }
const ethValueFormatted = formatTxValue( const ethValueFormatted = formatTxValue(
formatEther(approvedTx.value || "0"), formatEther(details.txParams.value || "0"),
); );
const ethPrice = getPrice("ETH"); const ethPrice = getPrice("ETH");
const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null; const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null;
@@ -282,8 +242,6 @@ function showTxApproval(details) {
$("approve-tx-value").textContent = $("approve-tx-value").textContent =
ethValueFormatted + " ETH" + (usdStr ? " (" + usdStr + ")" : ""); ethValueFormatted + " ETH" + (usdStr ? " (" + usdStr + ")" : "");
showTxFee(approvedTx, ethPrice);
// Decode calldata (reuse decoded from above) // Decode calldata (reuse decoded from above)
const decodedEl = $("approve-tx-decoded"); const decodedEl = $("approve-tx-decoded");
if (decoded) { if (decoded) {
@@ -312,8 +270,8 @@ function showTxApproval(details) {
} }
// Always show raw data when present // Always show raw data when present
if (approvedTx.data && approvedTx.data !== "0x") { if (details.txParams.data && details.txParams.data !== "0x") {
$("approve-tx-data").textContent = approvedTx.data; $("approve-tx-data").textContent = details.txParams.data;
$("approve-tx-data-section").classList.remove("hidden"); $("approve-tx-data-section").classList.remove("hidden");
} else { } else {
$("approve-tx-data-section").classList.add("hidden"); $("approve-tx-data-section").classList.add("hidden");
@@ -324,11 +282,7 @@ function showTxApproval(details) {
showView("approve-tx"); showView("approve-tx");
attachCopyHandlers("view-approve-tx"); attachCopyHandlers("view-approve-tx");
gateOnWalletDefect( gateOnWalletDefect("approve-tx-error", "btn-approve-tx");
"approve-tx-error",
"btn-approve-tx",
details.approvedFrom,
);
} }
function decodeHexMessage(hex) { function decodeHexMessage(hex) {
@@ -387,12 +341,9 @@ function showSignApproval(details) {
const sp = details.signParams; const sp = details.signParams;
pendingSignParams = sp; pendingSignParams = sp;
pendingSignFrom = details.approvedFrom;
$("approve-sign-hostname").textContent = details.hostname; $("approve-sign-hostname").textContent = details.hostname;
$("approve-sign-from").innerHTML = approvalAddressHtml( $("approve-sign-from").innerHTML = approvalAddressHtml(sp.from);
details.approvedFrom,
);
const isTyped = const isTyped =
sp.method === "eth_signTypedData_v4" || sp.method === "eth_signTypedData_v4" ||
@@ -431,27 +382,13 @@ function showSignApproval(details) {
showView("approve-sign"); showView("approve-sign");
attachCopyHandlers("view-approve-sign"); attachCopyHandlers("view-approve-sign");
gateOnWalletDefect( gateOnWalletDefect("approve-sign-error", "btn-approve-sign");
"approve-sign-error",
"btn-approve-sign",
details.approvedFrom,
);
} }
// Awaited by nobody: the popup entry point calls this and moves on. It function show(id) {
// therefore has to absorb its own failure, and a background that cannot
// describe the approval is the same outcome as an approval that is gone.
async function show(id) {
approvalId = id; approvalId = id;
runtimeApi().connect({ name: "approval:" + id }); runtime.connect({ name: "approval:" + id });
runtime.sendMessage({ type: "AUTISTMASK_GET_APPROVAL", id }, (details) => {
let details = null;
try {
details = await sendMessage({ type: "AUTISTMASK_GET_APPROVAL", id });
} catch {
details = null;
}
if (!details) { if (!details) {
window.close(); window.close();
return; return;
@@ -470,22 +407,21 @@ async function show(id) {
details.isPhishingDomain, details.isPhishingDomain,
); );
$("approve-hostname").textContent = details.hostname; $("approve-hostname").textContent = details.hostname;
$("approve-address").innerHTML = approvalAddressHtml(state.activeAddress); $("approve-address").innerHTML = approvalAddressHtml(
state.activeAddress,
);
attachCopyHandlers("view-approve-site"); attachCopyHandlers("view-approve-site");
$("approve-remember").checked = state.rememberSiteChoice; $("approve-remember").checked = state.rememberSiteChoice;
});
} }
let approvalId = null; let approvalId = null;
let pendingTxDetails = null; let pendingTxDetails = null;
// The exact objects shown to the user, kept so the popup signs what it // The exact parameters shown to the user, kept so the popup signs what it
// displayed rather than re-fetching or re-populating anything at approval // displayed rather than re-fetching anything at approval time. Both are
// time. All are repopulated by show() when the popup is closed and reopened. // repopulated by show() when the popup is closed and reopened.
let pendingTxParams = null; let pendingTxParams = null;
let pendingSignParams = null; let pendingSignParams = null;
// The address the approval was raised for. Signing uses this rather than the
// active address, so that an address switch since the approval fails here
// instead of producing a signature from an account the screen never named.
let pendingSignFrom = null;
// Approve buttons stay disabled and muted while the popup derives the key and // Approve buttons stay disabled and muted while the popup derives the key and
// signs, which is slow enough (Argon2id) that a double click is likely. // signs, which is slow enough (Argon2id) that a double click is likely.
@@ -500,13 +436,12 @@ function setSignButtonBusy(busy) {
} }
// Say so on the approval screen itself, and disable the approve button, when // Say so on the approval screen itself, and disable the approve button, when
// the address the approval was raised for belongs to a wallet whose keys // the active address belongs to a wallet whose keys cannot be derived. Without
// cannot be derived. Without this the screen would take a password and fail // this the screen would take a password and fail after deriving it. Reject
// after deriving it. Reject stays available; the wallet is not touched. // stays available; the wallet is not touched. Returns true when it gated.
// Returns true when it gated. function gateOnWalletDefect(errorId, buttonId) {
function gateOnWalletDefect(errorId, buttonId, address) { const active = findActiveWallet();
const owner = findWalletFor(address); const defect = active ? walletDefect(active.wallet) : null;
const defect = owner ? walletDefect(owner.wallet) : null;
if (!defect) return false; if (!defect) return false;
showError(errorId, defect.shortMessage); showError(errorId, defect.shortMessage);
$(buttonId).disabled = true; $(buttonId).disabled = true;
@@ -514,14 +449,12 @@ function gateOnWalletDefect(errorId, buttonId, address) {
return true; return true;
} }
// Locate the wallet and the address index owning an address. Returns null when // Locate the wallet and the address index owning the currently active
// no wallet holds it. Approvals look up the address they were raised for, not // address. Returns null when no wallet holds it.
// whichever address is active now: the approval named one account, and signing function findActiveWallet() {
// with another is what verification refuses.
function findWalletFor(address) {
for (const wallet of state.wallets) { for (const wallet of state.wallets) {
for (let i = 0; i < wallet.addresses.length; i++) { for (let i = 0; i < wallet.addresses.length; i++) {
if (wallet.addresses[i].address === address) { if (wallet.addresses[i].address === state.activeAddress) {
return { wallet, addrIndex: i }; return { wallet, addrIndex: i };
} }
} }
@@ -554,7 +487,7 @@ function init(ctx) {
$("btn-approve").addEventListener("click", () => { $("btn-approve").addEventListener("click", () => {
const remember = $("approve-remember").checked; const remember = $("approve-remember").checked;
notify({ runtime.sendMessage({
type: "AUTISTMASK_APPROVAL_RESPONSE", type: "AUTISTMASK_APPROVAL_RESPONSE",
id: approvalId, id: approvalId,
approved: true, approved: true,
@@ -565,7 +498,7 @@ function init(ctx) {
$("btn-reject").addEventListener("click", () => { $("btn-reject").addEventListener("click", () => {
const remember = $("approve-remember").checked; const remember = $("approve-remember").checked;
notify({ runtime.sendMessage({
type: "AUTISTMASK_APPROVAL_RESPONSE", type: "AUTISTMASK_APPROVAL_RESPONSE",
id: approvalId, id: approvalId,
approved: false, approved: false,
@@ -583,12 +516,12 @@ function init(ctx) {
hideError("approve-tx-error"); hideError("approve-tx-error");
setTxButtonBusy(true); setTxButtonBusy(true);
const active = findWalletFor(pendingTxParams.from); const active = findActiveWallet();
if (!active) { if (!active) {
password = null; password = null;
showError( showError(
"approve-tx-error", "approve-tx-error",
"No wallet was found for the address this transaction was approved for.", "No wallet was found for the active address.",
); );
setTxButtonBusy(false); setTxButtonBusy(false);
return; return;
@@ -635,16 +568,15 @@ function init(ctx) {
active.addrIndex, active.addrIndex,
decryptedSecret, decryptedSecret,
); );
// Sign the approved transaction exactly as it was displayed. The const provider = getProvider(state.rpcUrl);
// background populated it before this screen was drawn and checks const connected = signer.connect(provider);
// the artifact against it field for field, so there is nothing to // This is the sequence ethers' own sendTransaction() runs
// fill in here and no provider to fill it in from. The copy is // internally, so nonce, gas, fee and chain id population are
// because ethers may strip `from` off what it is handed, and the // identical to when the background did the signing.
// approval has to survive a retry intact; keeping `from` on it const populated =
// makes ethers refuse a key that is not the approved address. await connected.populateTransaction(pendingTxParams);
payload.rawSignedTx = await signer.signTransaction({ delete populated.from;
...pendingTxParams, payload.rawSignedTx = await connected.signTransaction(populated);
});
} catch (e) { } catch (e) {
payload.error = payload.error =
e.shortMessage || e.message || "Transaction signing failed."; e.shortMessage || e.message || "Transaction signing failed.";
@@ -654,37 +586,19 @@ function init(ctx) {
decryptedSecret = null; decryptedSecret = null;
} }
// A send that never reaches the background is reported to the user runtime.sendMessage(payload, (response) => {
// the same way a background that refused it is: describeSigningFailure
// turns a null response into the generic message below.
let response = null;
try {
response = await sendMessage(payload);
} catch {
response = null;
}
if (response && response.txHash) { if (response && response.txHash) {
txStatus.showWait(pendingTxDetails, response.txHash); txStatus.showWait(pendingTxDetails, response.txHash);
return;
}
// A retryable failure leaves the approval pending in the
// background, so stay on this screen with a live button rather
// than sending the user to a dead end.
const outcome = describeSigningFailure(
response,
"The transaction could not be sent.",
);
if (outcome.retryable) {
showError("approve-tx-error", outcome.message);
setTxButtonBusy(false);
} else { } else {
txStatus.showError(pendingTxDetails, null, outcome.message); const msg =
(response && response.error) || "Transaction failed.";
txStatus.showError(pendingTxDetails, null, msg);
} }
}); });
});
$("btn-reject-tx").addEventListener("click", () => { $("btn-reject-tx").addEventListener("click", () => {
notify({ runtime.sendMessage({
type: "AUTISTMASK_TX_RESPONSE", type: "AUTISTMASK_TX_RESPONSE",
id: approvalId, id: approvalId,
approved: false, approved: false,
@@ -701,12 +615,12 @@ function init(ctx) {
hideError("approve-sign-error"); hideError("approve-sign-error");
setSignButtonBusy(true); setSignButtonBusy(true);
const active = findWalletFor(pendingSignFrom); const active = findActiveWallet();
if (!active) { if (!active) {
password = null; password = null;
showError( showError(
"approve-sign-error", "approve-sign-error",
"No wallet was found for the address this request was approved for.", "No wallet was found for the active address.",
); );
setSignButtonBusy(false); setSignButtonBusy(false);
return; return;
@@ -778,31 +692,19 @@ function init(ctx) {
decryptedSecret = null; decryptedSecret = null;
} }
let response = null; runtime.sendMessage(payload, (response) => {
try {
response = await sendMessage(payload);
} catch {
response = null;
}
if (response && response.signature) { if (response && response.signature) {
window.close(); window.close();
return; } else {
const msg = (response && response.error) || "Signing failed.";
showError("approve-sign-error", msg);
setSignButtonBusy(false);
} }
// The button comes back only when the approval is still pending in });
// the background; otherwise it stays disabled and the message says
// why, because a control that cannot succeed must not look like it
// can.
const outcome = describeSigningFailure(
response,
"The message could not be signed.",
);
showError("approve-sign-error", outcome.message);
if (outcome.retryable) setSignButtonBusy(false);
}); });
$("btn-reject-sign").addEventListener("click", () => { $("btn-reject-sign").addEventListener("click", () => {
notify({ runtime.sendMessage({
type: "AUTISTMASK_SIGN_RESPONSE", type: "AUTISTMASK_SIGN_RESPONSE",
id: approvalId, id: approvalId,
approved: false, approved: false,

View File

@@ -422,10 +422,7 @@ function init(ctx) {
password, password,
); );
} catch (e) { } catch (e) {
showError( showError("confirm-tx-password-error", "Wrong password.");
"confirm-tx-password-error",
"That password is incorrect. Please try again.",
);
return; return;
} }

View File

@@ -1,176 +0,0 @@
// Confirmation screen for removing one address from a wallet that derives
// its addresses from an extended key.
//
// No password is asked for, unlike delete-wallet. A password gates the
// disclosure or destruction of a secret, and this does neither: the address
// is derived from key material the wallet still holds, so removing it only
// stops the wallet tracking it. An explicit confirmation screen is the
// proportionate treatment.
const {
$,
showView,
showFlash,
goBack,
renderAddressHtml,
attachCopyHandlers,
addressHoldsFunds,
balanceLinesForAddress,
} = require("./helpers");
const { formatUsd, getAddressValueUsd } = require("../../shared/prices");
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
const { state, saveState } = require("../../shared/state");
const {
canRemoveAddress,
removeAddressFromState,
broadcastActiveChanged,
} = require("../../shared/walletDelete");
// The wallet and address indices this screen is confirming, or null when it
// is not confirming anything.
let target = null;
let ctx = null;
function setFlash(msg) {
const el = $("delete-address-flash");
el.textContent = msg;
el.style.visibility = msg ? "visible" : "hidden";
}
// What it actually takes to get the address back, which is not what the
// screen used to claim.
//
// Neither obvious route works: "+" derives the next unused index, because
// wallet.nextIndex is a high-water mark and is deliberately not rewound; and
// re-importing this wallet's key material is refused as a duplicate by
// findWalletByXpub() for as long as the wallet is here. What remains is to
// delete the whole wallet in Settings — which asks for the password and
// destroys the stored secret — and import again, after which
// scanForAddresses() rediscovers the address only if it has on-chain
// activity. An address that was never used is not found by that scan, and
// the copy must not imply otherwise.
//
// The noun follows the wallet: an xprv wallet holds no recovery phrase, and
// this screen is offered on xprv wallets too.
function recoveryPathText(wallet) {
const secret = walletHasRecoveryPhrase(wallet)
? "recovery phrase"
: "extended private key";
return (
"Getting the address back into this list is not easy, so be sure. " +
"Adding an address derives the next unused one, not this one, and " +
"importing this " +
secret +
" again is refused while this wallet is still here. The way back is " +
"to delete the whole wallet in Settings, which asks for your " +
"password and destroys the stored " +
secret +
", and then import that " +
secret +
" again. The scan that follows only finds addresses that have " +
"on-chain activity, so an address that has never been used is not " +
"found by it."
);
}
// The balance warning, or a blank line when the address holds nothing.
//
// A balance is a reason to be careful, not a reason to refuse: the funds are
// at the address, not in this list, and stay there either way.
//
// "Holds" means ETH or any ERC-20 the wallet knows about — an address with no
// ETH and a five-figure stablecoin position must not get the blank line on
// the one screen whose job is to warn. The sentence names no figure of its
// own: the rendered lines round to four decimals, so a sentence built from a
// rounded number would report "0.0000 ETH" for an address holding real money.
// The lines below it carry the amounts, in the same format as Home and
// AddressDetail, followed by the USD total when prices are known (null on
// testnet and before the first price fetch, where the line is left off rather
// than printed as $0.00).
function balanceWarningHtml(addr) {
if (!addressHoldsFunds(addr)) return "&nbsp;";
const usd = getAddressValueUsd(addr);
const total =
usd === null
? ""
: `<div class="text-xs text-muted mt-1">Total: ${formatUsd(usd)}</div>`;
return (
`<p class="mb-1">This address holds a balance. Removing it does not ` +
`move or spend anything; the balance stays at the address.</p>` +
balanceLinesForAddress(addr, state.trackedTokens, false) +
total
);
}
function show(walletIdx, addrIdx) {
const wallet = state.wallets[walletIdx];
const addr = wallet && wallet.addresses[addrIdx];
if (!addr) return;
target = { walletIdx, addrIdx };
$("delete-address-label").textContent = "Address " + (addrIdx + 1);
$("delete-address-wallet-name").textContent =
wallet.name || "Wallet " + (walletIdx + 1);
const value = $("delete-address-value");
value.innerHTML = renderAddressHtml(addr.address, {
ensName: addr.ensName,
});
attachCopyHandlers(value);
$("delete-address-recovery").textContent = recoveryPathText(wallet);
$("delete-address-balance").innerHTML = balanceWarningHtml(addr);
setFlash("");
showView("delete-address-confirm");
}
function init(_ctx) {
ctx = _ctx;
$("btn-delete-address-back").addEventListener("click", () => {
target = null;
goBack();
});
$("btn-delete-address-confirm").addEventListener("click", async () => {
if (target === null) {
setFlash("No address is selected for removal.");
return;
}
const { walletIdx, addrIdx } = target;
if (!canRemoveAddress(state.wallets[walletIdx])) {
setFlash(
"This address cannot be removed, because a wallet always " +
"keeps at least one address.",
);
return;
}
const { removed, activeAddressChanged } = removeAddressFromState(
state,
walletIdx,
addrIdx,
);
if (!removed) {
setFlash("This address could not be removed.");
return;
}
target = null;
// Save before broadcasting: the background reads the active address
// back out of storage to build accountsChanged.
await saveState();
if (activeAddressChanged) broadcastActiveChanged();
ctx.renderWalletList();
goBack();
showFlash("Address removed.");
});
}
// recoveryPathText and balanceWarningHtml are exported so the two pieces of
// copy that carry the screen's substance can be tested without a DOM; show()
// is a one-line assignment for each.
module.exports = { init, show, recoveryPathText, balanceWarningHtml };

View File

@@ -74,8 +74,7 @@ function init(_ctx) {
try { try {
await decryptWithPassword(wallet.encryptedSecret, pw); await decryptWithPassword(wallet.encryptedSecret, pw);
} catch (_e) { } catch (_e) {
$("delete-wallet-flash").textContent = $("delete-wallet-flash").textContent = "Wrong password.";
"That password is incorrect. Please try again.";
$("delete-wallet-flash").style.visibility = "visible"; $("delete-wallet-flash").style.visibility = "visible";
btn.disabled = false; btn.disabled = false;
btn.classList.remove("text-muted"); btn.classList.remove("text-muted");

View File

@@ -144,7 +144,7 @@ async function reveal() {
$("export-privkey-flash").style.visibility = "hidden"; $("export-privkey-flash").style.visibility = "hidden";
} catch { } catch {
if (!isCurrentReveal(generation)) return; if (!isCurrentReveal(generation)) return;
fail("That password is incorrect. Please try again."); fail("That password is not correct. Please try again.");
} finally { } finally {
btn.disabled = false; btn.disabled = false;
btn.classList.remove("text-muted"); btn.classList.remove("text-muted");

View File

@@ -25,7 +25,6 @@ const VIEWS = [
"add-token", "add-token",
"settings", "settings",
"delete-wallet-confirm", "delete-wallet-confirm",
"delete-address-confirm",
"settings-addtoken", "settings-addtoken",
"transaction", "transaction",
"approve-site", "approve-site",
@@ -218,20 +217,6 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
return html; return html;
} }
// Whether an address holds anything at all: ETH or any ERC-20 the wallet
// knows about. Deliberately unrounded — the rendered lines round to four
// decimals, so a dust balance displays as 0.0000 while still being real
// money at a real address. Callers that warn about holdings must ask this,
// not the rendered figure.
function addressHoldsFunds(addr) {
if (!addr) return false;
if (parseFloat(addr.balance || "0") > 0) return true;
for (const t of addr.tokenBalances || []) {
if (parseFloat(t.balance || "0") > 0) return true;
}
return false;
}
// Truncate the middle of a string, replacing removed characters with "…". // Truncate the middle of a string, replacing removed characters with "…".
// Safety: refuses to truncate more than 10 characters, which is the maximum // Safety: refuses to truncate more than 10 characters, which is the maximum
// that still prevents address spoofing attacks (see Display Consistency in // that still prevents address spoofing attacks (see Display Consistency in
@@ -478,7 +463,6 @@ module.exports = {
flashCopyFeedback, flashCopyFeedback,
balanceLine, balanceLine,
balanceLinesForAddress, balanceLinesForAddress,
addressHoldsFunds,
addressColor, addressColor,
addressDotHtml, addressDotHtml,
escapeHtml, escapeHtml,

View File

@@ -15,14 +15,12 @@ const {
pushCurrentView, pushCurrentView,
} = require("./helpers"); } = require("./helpers");
const { state, saveState, currentAddress } = require("../../shared/state"); const { state, saveState, currentAddress } = require("../../shared/state");
const { notify } = require("../../shared/browserApi");
const { const {
updateSendBalance, updateSendBalance,
renderSendTokenSelect, renderSendTokenSelect,
resetSendValidation, resetSendValidation,
} = require("./send"); } = require("./send");
const { deriveAddressFromXpub } = require("../../shared/wallet"); const { deriveAddressFromXpub } = require("../../shared/wallet");
const { canRemoveAddress } = require("../../shared/walletDelete");
const { const {
walletDefect, walletDefect,
walletDefectHtml, walletDefectHtml,
@@ -242,12 +240,6 @@ function walletListHtml() {
html += `<div class="address-row py-1 border-b border-border-light cursor-pointer hover:bg-hover" data-wallet="${wi}" data-address="${ai}">`; html += `<div class="address-row py-1 border-b border-border-light cursor-pointer hover:bg-hover" data-wallet="${wi}" data-address="${ai}">`;
const isActive = state.activeAddress === addr.address; const isActive = state.activeAddress === addr.address;
const infoBtn = `<span class="btn-addr-info text-xs cursor-pointer border border-border hover:bg-fg hover:text-bg" style="padding:0" data-wallet="${wi}" data-address="${ai}">[info]</span>`; const infoBtn = `<span class="btn-addr-info text-xs cursor-pointer border border-border hover:bg-fg hover:text-bg" style="padding:0" data-wallet="${wi}" data-address="${ai}">[info]</span>`;
// Only where a wallet can spare the address: a wallet holding a
// single address has no remove control, because its last address
// is never removable.
const removeBtn = canRemoveAddress(wallet)
? `<span class="btn-remove-address text-xs cursor-pointer border border-border hover:bg-fg hover:text-bg ml-1" style="padding:0" data-wallet="${wi}" data-address="${ai}" title="Remove this address from the wallet">[x]</span>`
: "";
const dot = addressDotHtml(addr.address); const dot = addressDotHtml(addr.address);
const titleBold = isActive ? "font-bold" : ""; const titleBold = isActive ? "font-bold" : "";
html += `<div class="text-xs ${titleBold}">Address ${ai + 1}</div>`; html += `<div class="text-xs ${titleBold}">Address ${ai + 1}</div>`;
@@ -256,7 +248,7 @@ function walletListHtml() {
} }
html += `<div class="flex text-xs items-center justify-between">`; html += `<div class="flex text-xs items-center justify-between">`;
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}</span>`;
html += `</div>`; html += `</div>`;
const addrUsd = formatUsd(getAddressValueUsd(addr)); const addrUsd = formatUsd(getAddressValueUsd(addr));
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrUsd || "&nbsp;"}</div>`; html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrUsd || "&nbsp;"}</div>`;
@@ -294,7 +286,11 @@ function render(ctx) {
state.activeAddress = addr; state.activeAddress = addr;
await saveState(); await saveState();
render(ctx); render(ctx);
notify({ type: "AUTISTMASK_ACTIVE_CHANGED" }); const runtime =
typeof browser !== "undefined"
? browser.runtime
: chrome.runtime;
runtime.sendMessage({ type: "AUTISTMASK_ACTIVE_CHANGED" });
} }
}); });
}); });
@@ -308,16 +304,6 @@ function render(ctx) {
}); });
}); });
container.querySelectorAll(".btn-remove-address").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
ctx.showDeleteAddress(
parseInt(btn.dataset.wallet, 10),
parseInt(btn.dataset.address, 10),
);
});
});
container.querySelectorAll(".btn-add-address").forEach((btn) => { container.querySelectorAll(".btn-add-address").forEach((btn) => {
btn.addEventListener("click", async (e) => { btn.addEventListener("click", async (e) => {
e.stopPropagation(); e.stopPropagation();

View File

@@ -12,9 +12,8 @@ const {
const { state, currentAddress } = require("../../shared/state"); const { state, currentAddress } = require("../../shared/state");
let ctx; let ctx;
const { getProvider } = require("../../shared/balances"); const { getProvider } = require("../../shared/balances");
const { resolveSymbol } = require("../../shared/tokenList"); const { KNOWN_SYMBOLS, resolveSymbol } = require("../../shared/tokenList");
const { isLowHolderCount } = require("../../shared/holders"); const { isLowHolderCount } = require("../../shared/holders");
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
const { getAddress } = require("ethers"); const { getAddress } = require("ethers");
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
@@ -117,6 +116,14 @@ function updateToValidation() {
} }
} }
function isSpoofedToken(t) {
const upper = (t.symbol || "").toUpperCase();
if (!KNOWN_SYMBOLS.has(upper)) return false;
const legit = KNOWN_SYMBOLS.get(upper);
if (legit === null) return true;
return t.address.toLowerCase() !== legit;
}
function renderSendTokenSelect(addr) { function renderSendTokenSelect(addr) {
const sel = $("send-token"); const sel = $("send-token");
sel.innerHTML = '<option value="ETH">ETH</option>'; sel.innerHTML = '<option value="ETH">ETH</option>';
@@ -124,7 +131,7 @@ function renderSendTokenSelect(addr) {
(state.fraudContracts || []).map((a) => a.toLowerCase()), (state.fraudContracts || []).map((a) => a.toLowerCase()),
); );
for (const t of addr.tokenBalances || []) { for (const t of addr.tokenBalances || []) {
if (isSpoofedSymbol(t.symbol, t.address)) continue; if (isSpoofedToken(t)) continue;
if (fraudSet.has(t.address.toLowerCase())) continue; if (fraudSet.has(t.address.toLowerCase())) continue;
// An unknown holder count does not withhold a token the user holds: // An unknown holder count does not withhold a token the user holds:
// only a count the explorer actually reported as below the threshold // only a count the explorer actually reported as below the threshold

View File

@@ -9,10 +9,6 @@ const {
pushCurrentView, pushCurrentView,
} = require("./helpers"); } = require("./helpers");
const { applyTheme } = require("../theme"); const { applyTheme } = require("../theme");
const {
DUST_THRESHOLD_MESSAGE,
parseDustThresholdGwei,
} = require("../dustThreshold");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { NETWORKS, SUPPORTED_CHAIN_IDS } = require("../../shared/networks"); const { NETWORKS, SUPPORTED_CHAIN_IDS } = require("../../shared/networks");
const { onChainSwitch } = require("../../shared/chainSwitch"); const { onChainSwitch } = require("../../shared/chainSwitch");
@@ -29,7 +25,8 @@ const {
GITEA_COMMIT_URL, GITEA_COMMIT_URL,
} = require("../../shared/buildInfo"); } = require("../../shared/buildInfo");
const { notify } = require("../../shared/browserApi"); const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
let versionClickCount = 0; let versionClickCount = 0;
let versionClickTimer = null; let versionClickTimer = null;
@@ -60,7 +57,7 @@ function renderSiteList(containerId, siteMap, stateKey) {
} }
} }
await saveState(); await saveState();
notify({ type: "AUTISTMASK_REMOVE_SITE" }); runtime.sendMessage({ type: "AUTISTMASK_REMOVE_SITE" });
renderSiteList(containerId, state[key], key); renderSiteList(containerId, state[key], key);
}); });
}); });
@@ -332,14 +329,13 @@ function init(ctx) {
$("settings-dust-threshold").value = state.dustThresholdGwei; $("settings-dust-threshold").value = state.dustThresholdGwei;
$("settings-dust-threshold").addEventListener("change", async () => { $("settings-dust-threshold").addEventListener("change", async () => {
const val = parseDustThresholdGwei($("settings-dust-threshold").value); const raw = $("settings-dust-threshold").value.trim();
// Rejected input is never coerced. The field is put back to the const val = Number(raw);
// stored threshold so it never shows a value the wallet is not // 0 is accepted and means "hide nothing". Empty, negative,
// using, and the message says what the field wants so the snap-back // fractional and non-numeric input is rejected outright rather than
// is explained rather than silent. // coerced, and the field is put back to the stored threshold so it
if (val === null) { // never shows a value the wallet is not using.
showFlash(DUST_THRESHOLD_MESSAGE); if (raw !== "" && Number.isInteger(val) && val >= 0) {
} else {
state.dustThresholdGwei = val; state.dustThresholdGwei = val;
await saveState(); await saveState();
} }

View File

@@ -126,7 +126,7 @@ async function reveal() {
if (!isCurrentReveal(generation)) return; if (!isCurrentReveal(generation)) return;
// Deliberately not the caught error: the message is fixed so that // Deliberately not the caught error: the message is fixed so that
// nothing derived from the ciphertext or the attempt can surface. // nothing derived from the ciphertext or the attempt can surface.
fail("That password is incorrect. Please try again."); fail("That password is not correct. Please try again.");
} finally { } finally {
btn.disabled = false; btn.disabled = false;
btn.classList.remove("text-muted"); btn.classList.remove("text-muted");

View File

@@ -19,8 +19,6 @@
// be bypassed on the scheduled tick — see backgroundRefresh() in // be bypassed on the scheduled tick — see backgroundRefresh() in
// src/background/index.js and updatePhishingList() in shared/phishingDomains.js. // src/background/index.js and updatePhishingList() in shared/phishingDomains.js.
const { alarmsApi } = require("./browserApi");
const BALANCE_REFRESH_ALARM = "autistmask-balance-refresh"; const BALANCE_REFRESH_ALARM = "autistmask-balance-refresh";
const PHISHING_REFRESH_ALARM = "autistmask-phishing-refresh"; const PHISHING_REFRESH_ALARM = "autistmask-phishing-refresh";
@@ -28,10 +26,14 @@ const MIN_ALARM_PERIOD_MINUTES = 1;
const BALANCE_REFRESH_PERIOD_MINUTES = 1; const BALANCE_REFRESH_PERIOD_MINUTES = 1;
const PHISHING_REFRESH_PERIOD_MINUTES = 24 * 60; const PHISHING_REFRESH_PERIOD_MINUTES = 24 * 60;
// alarmsApi() resolves on use rather than at module load: the worker is torn // Resolved on use rather than captured at module load: the worker is torn
// down and re-evaluated repeatedly, and tests install a stub after requiring // down and re-evaluated repeatedly, and tests install a stub after requiring
// this module. It returns null where the API is absent, which is why every // the module.
// entry point below degrades instead of throwing. function alarmsApi() {
if (typeof browser !== "undefined" && browser.alarms) return browser.alarms;
if (typeof chrome !== "undefined" && chrome.alarms) return chrome.alarms;
return null;
}
/** /**
* Create an alarm unless one with the requested period already exists. * Create an alarm unless one with the requested period already exists.

View File

@@ -1,213 +0,0 @@
// Preparation of the transaction an approval screen displays.
//
// A dApp's eth_sendTransaction normally fixes only `to`, `value` and `data`.
// The nonce, the gas limit and the fees have to be filled in from the network
// before anything can be signed, and whoever fills them in decides what the
// user is shown. That work used to happen in the popup, after the user had
// already approved: the numbers on the approval screen came from the popup and
// were compared against nothing, so a compromised popup could display one fee
// and sign another, and the ceilings in approvalVerify.js were all that stood
// between the user and a fee that hands the validator the balance.
//
// So it happens here instead, in the background, before the approval window is
// opened. The background populates the transaction, shows that object, and
// verifies the signed artifact against that same object — the popup is handed
// a finished transaction and signs it as given. Every field the user reads is
// then a field that is compared.
//
// The cost is an RPC round trip before the approval window exists. Nothing is
// displayed while it is in flight, and a failure — an unreachable node, a
// reverting gas estimate, a transaction type this wallet does not sign, a fee
// past the ceilings — means no approval and no window at all: the error goes
// back to the requesting page, which is where the user's click came from. That
// is deliberate. The alternative, opening the window first and populating
// behind a spinner, needs a pending approval that exists before it can be
// displayed or signed, and a half-initialised approval is exactly the state
// the settle interlock in the background exists to keep out of that record.
// The failure also lands earlier than it used to rather than later: the same
// estimate previously failed after the user had typed their password.
const {
VoidSigner,
accessListify,
getAddress,
getBytes,
hexlify,
toQuantity,
} = require("ethers");
const {
ALLOWED_TX_TYPES,
SERIALIZED_FIELDS,
assertWithinCeilings,
} = require("./approvalVerify");
// How long the population may take before the request is failed back to the
// page. Without a bound a hung RPC endpoint leaves the dApp's promise pending
// forever with nothing on screen to explain it; ethers' own request timeout is
// minutes long, which is not a wait anyone will sit through.
const POPULATE_TIMEOUT_MS = 20000;
// The request fields taken from the page. Anything else is dropped rather than
// passed to ethers: the object is page-controlled, and a future ethers that
// learns to carry a new transaction field must not start picking one up out of
// it without this module knowing.
const REQUEST_FIELDS = [
"to",
"value",
"data",
"nonce",
"gasLimit",
"gasPrice",
"maxFeePerGas",
"maxPriorityFeePerGas",
"chainId",
"accessList",
"type",
];
class ApprovalPrepareError extends Error {
constructor(message) {
super(message);
this.name = "ApprovalPrepareError";
}
}
function fail(message) {
return new ApprovalPrepareError(message);
}
function present(v) {
return v !== null && v !== undefined && v !== "";
}
// These strings reach the user through the requesting page, so they are full
// sentences even when the tail of one came from ethers or from the node.
function sentence(text) {
return /[.!?]$/.test(text) ? text : text + ".";
}
// Reject a promise that has taken too long, and never leave the timer behind.
async function withTimeout(promise, ms, message) {
let timer = null;
try {
return await Promise.race([
promise,
new Promise((_resolve, reject) => {
timer = setTimeout(() => reject(fail(message)), ms);
}),
]);
} finally {
if (timer !== null) clearTimeout(timer);
}
}
// The page's request, reduced to the fields this wallet acts on.
function requestFrom(txParams, from) {
const request = { from: getAddress(from) };
for (const key of REQUEST_FIELDS) {
if (present(txParams[key])) request[key] = txParams[key];
}
if (
present(request.type) &&
!ALLOWED_TX_TYPES.includes(Number(request.type))
) {
throw fail(
"The site asked for a transaction of a type this wallet does not sign.",
);
}
return request;
}
// Turn a populated transaction into the object that crosses to the popup, is
// displayed, and is compared with the signed artifact. It carries exactly the
// fields its type serializes, plus the address it is to be signed by, and
// every quantity as a hex string: extension messaging is JSON, which has no
// bigint, and a field that did not survive the trip would be a field the user
// was shown and nothing compared.
function serializeApprovedTx(populated, from) {
const type = Number(populated.type);
if (!ALLOWED_TX_TYPES.includes(type)) {
throw fail(
"This transaction would have to be sent as a type this wallet does not sign.",
);
}
const approved = { type, from: getAddress(from) };
for (const key of SERIALIZED_FIELDS[type]) {
if (key === "to") {
approved.to = present(populated.to)
? getAddress(populated.to)
: null;
} else if (key === "data") {
approved.data = present(populated.data)
? hexlify(getBytes(populated.data))
: "0x";
} else if (key === "accessList") {
approved.accessList = accessListify(populated.accessList || []);
} else if (key === "value") {
approved.value = toQuantity(populated.value || 0);
} else if (!present(populated[key])) {
// Unreachable while populateTransaction() fills every quantity of
// the type it produced. If it ever does not, the approval must not
// be raised: an unfixed quantity is one the artifact cannot be
// checked against.
throw fail(
"The transaction could not be prepared: the network did not supply a " +
key +
".",
);
} else {
approved[key] = toQuantity(populated[key]);
}
}
return approved;
}
// Populate the transaction a site asked for, as the address it will be signed
// by, and return the object to display, sign and verify against. Throws with a
// full sentence when no approval can be raised.
async function prepareApprovalTx(provider, from, txParams) {
if (!present(from)) {
throw fail("There is no active address to send this transaction from.");
}
const request = requestFrom(txParams || {}, from);
let populated;
try {
// The sequence ethers' own sendTransaction() runs internally, so the
// nonce, gas, fee and chain id are populated exactly as they were when
// the popup did this. VoidSigner cannot sign, which is the point: the
// background prepares, the popup signs.
populated = await withTimeout(
new VoidSigner(getAddress(from), provider).populateTransaction(
request,
),
POPULATE_TIMEOUT_MS,
"The transaction could not be prepared: the network did not answer in time.",
);
} catch (e) {
if (e instanceof ApprovalPrepareError) throw e;
throw fail(
sentence(
"The transaction could not be prepared: " +
(e.shortMessage ||
e.message ||
"the network did not answer"),
),
);
}
const approved = serializeApprovedTx(populated, from);
// The backstop, applied before the user is shown anything rather than
// after they have approved it: what is displayed here is what gets signed,
// so an RPC node reporting an absurd fee has to be refused here.
assertWithinCeilings(approved);
return approved;
}
module.exports = {
prepareApprovalTx,
serializeApprovedTx,
ApprovalPrepareError,
POPULATE_TIMEOUT_MS,
REQUEST_FIELDS,
};

View File

@@ -7,160 +7,17 @@
// the signer from the artifact and checks it against the approval it is // the signer from the artifact and checks it against the approval it is
// holding before acting on it. All recovery is delegated to ethers. // holding before acting on it. All recovery is delegated to ethers.
// //
// What the artifact is checked against is the transaction the background
// populated and the popup displayed (see approvalTx.js), not the request the
// dApp made. The two differ in every field a dApp normally leaves out — nonce,
// gas limit, fees — and those are the fields the user reads off the approval
// screen, so comparing against the request would leave the numbers on screen
// vouched for by nothing.
//
// The check is an allowlist, in both directions, because a denylist cannot be
// correct against a transaction format that keeps gaining fields:
//
// - only transaction types 0, 1 and 2 are accepted. Every later EIP-2718 type
// adds a field with consequences of its own — EIP-7702's authorizationList
// rewrites the code at the signer's own account, EIP-4844's blob
// commitments carry a separate fee — and a check that enumerates the fields
// it refuses admits every one of them by default.
// - after the per-field comparisons, the artifact is rebuilt from those
// checked fields and nothing else, and the two are compared byte for byte.
// Anything the artifact carries that this module does not name is absent
// from the rebuild and changes the bytes, so the final assertion is that
// the artifact *is* the approved transaction, not merely that it is not one
// of the tampered shapes that were thought of.
// - every comparison runs against the decode, but the string handed to
// broadcastTransaction() is the artifact. So the artifact is also required
// to be the canonical re-encoding of its own decode, which is what makes
// the checked transaction and the broadcast bytes the same object rather
// than two things that merely decode alike.
//
// Every consequential field is compared, and a mismatch is a refusal to act,
// never a warning: what the user approved is what gets broadcast, or nothing
// does.
//
// The approved transaction is required to fix every field its type serializes,
// so there is no "the approval did not say" branch to fall through: a quantity
// the approval does not carry is a refusal, because an artifact that cannot be
// compared with what was displayed has not been checked. The chain id is
// checked against the selected network as well as against the approval, which
// is what makes a cross-chain replay impossible.
//
// Every failure message is a full sentence, because these strings are shown to // Every failure message is a full sentence, because these strings are shown to
// the user and returned to the dApp. // the user and returned to the dApp.
const { const {
Transaction, Transaction,
accessListify,
getAddress, getAddress,
getBytes, getBytes,
verifyMessage, verifyMessage,
verifyTypedData, verifyTypedData,
} = require("ethers"); } = require("ethers");
// The only transaction types this wallet signs: legacy, EIP-2930 and
// EIP-1559. populateTransaction() produces nothing else, so nothing else can
// be an artifact of an approval this wallet raised.
const ALLOWED_TX_TYPES = [0, 1, 2];
// The serialized fields of each allowed type, which is also the complete set
// of fields the checks below compare or bound. The artifact is rebuilt from
// exactly these at the end of verification and compared byte for byte, so a
// field outside this table cannot ride along unexamined.
const SERIALIZED_FIELDS = {
0: ["chainId", "nonce", "gasPrice", "gasLimit", "to", "value", "data"],
1: [
"chainId",
"nonce",
"gasPrice",
"gasLimit",
"to",
"value",
"data",
"accessList",
],
2: [
"chainId",
"nonce",
"maxPriorityFeePerGas",
"maxFeePerGas",
"gasLimit",
"to",
"value",
"data",
"accessList",
],
};
// Fields no allowed type may carry. The type allowlist already excludes every
// type that defines them, and the structural check at the end of verification
// would catch them anyway; they are named here so that an artifact carrying
// one is refused with a message that says what it was.
const FORBIDDEN_FIELDS = [
{
key: "authorizationList",
message:
"The signed transaction would hand the signing account over to another contract, which was not approved.",
},
{
key: "blobVersionedHashes",
message:
"The signed transaction carries blob commitments, which were not approved.",
},
{
key: "blobs",
message:
"The signed transaction carries blobs, which were not approved.",
},
{
key: "maxFeePerBlobGas",
message:
"The signed transaction carries a blob gas fee, which was not approved.",
},
];
// Absolute ceilings — a BACKSTOP, not the primary control.
//
// The primary control is equality: every field of the artifact is compared
// with the populated transaction the user was shown, so nothing the popup
// signs can differ from the screen. What equality cannot bound is the
// populated transaction itself, which is built from what the configured RPC
// node answered — a node that reports an absurd fee gets that fee displayed,
// and a user who does not read the fee line would approve it. These ceilings
// bound that, and they are therefore applied where the transaction is
// populated (approvalTx.js) as well as here.
//
// Above the block gas limit of every supported network (see networks.js), so
// no transaction that could ever be included is refused by it.
const MAX_GAS_LIMIT = 100000000n;
// 100,000 gwei per gas: orders of magnitude above the highest fee either
// supported network has produced, and low enough to catch a fee that would
// hand the validator the balance.
const MAX_FEE_PER_GAS = 100000000000000n;
// A refusal to act on an artifact: it is not the thing that was approved, so
// the approval it was offered against is spent and must not be retried. Every
// throw in this module is one of these; the background distinguishes them from
// transient failures (a busy node, a failed broadcast), which leave the
// approval standing so the user can try again.
class ApprovalMismatchError extends Error {
constructor(message) {
super(message);
this.name = "ApprovalMismatchError";
this.approvalMismatch = true;
}
}
function refuse(message) {
return new ApprovalMismatchError(message);
}
// Whether a signing failure leaves the approval usable. Anything that is not a
// mismatch is the user's to correct and retry.
function failureIsRetryable(err) {
return !(err && err.approvalMismatch === true);
}
// Case-insensitive address comparison that tolerates absent values on either // Case-insensitive address comparison that tolerates absent values on either
// side. Two absent addresses compare equal (contract creation has no `to`). // side. Two absent addresses compare equal (contract creation has no `to`).
function sameAddress(a, b) { function sameAddress(a, b) {
@@ -174,64 +31,11 @@ function sameAddress(a, b) {
} }
} }
// Whether the approval fixed a value for a field at all.
function present(v) {
return v !== null && v !== undefined && v !== "";
}
// Whether a field carries anything at all. An empty array is nothing: ethers
// reports an absent access list on a type 2 transaction as `[]`.
function carriesValue(v) {
if (!present(v)) return false;
if (Array.isArray(v)) return v.length > 0;
return true;
}
// Normalize a quantity that must be present, refusing anything that is not a
// number: an approval carrying junk in a fee field cannot be compared, and an
// uncomparable field is a refusal rather than a pass.
function normalizeQuantity(v, label) {
try {
return BigInt(v);
} catch {
throw refuse(
"The approved " +
label +
" is not a number, so it cannot be" +
" compared with the signed transaction.",
);
}
}
// Normalize a transaction value (hex string, decimal string, number or // Normalize a transaction value (hex string, decimal string, number or
// bigint) to a bigint. An absent value is zero, matching ethers. The value is // bigint) to a bigint. An absent value is zero, matching ethers.
// page-controlled, so it goes through the same refusal as every other
// quantity rather than throwing a raw BigInt conversion error.
function normalizeValue(v) { function normalizeValue(v) {
if (!present(v)) return 0n; if (v === null || v === undefined || v === "") return 0n;
return normalizeQuantity(v, "value"); return BigInt(v);
}
// Normalize an access list to a comparable string. An absent or empty list is
// the empty string, so absent and `[]` are the same thing.
function normalizeAccessList(v) {
if (!carriesValue(v)) return "";
let list;
try {
list = accessListify(v);
} catch {
throw refuse(
"The approved access list is not a valid access list, so it cannot be compared with the signed transaction.",
);
}
return list
.map(
(entry) =>
String(entry.address).toLowerCase() +
":" +
entry.storageKeys.map((k) => String(k).toLowerCase()).join(","),
)
.join(";");
} }
// Normalize call data to a lowercase hex string. Absent data is "0x". // Normalize call data to a lowercase hex string. Absent data is "0x".
@@ -240,320 +44,45 @@ function normalizeData(v) {
return String(v).toLowerCase(); return String(v).toLowerCase();
} }
// How each field of an approved transaction is compared with the artifact.
// There is an entry here for every field any allowed type serializes — a test
// pins that against SERIALIZED_FIELDS — so the comparison loop covers the
// whole of what gets signed and cannot silently skip a field for want of a
// comparator.
//
// `kind` decides how the two sides are made comparable. A `quantity` must be
// fixed by the approval: it is one of the numbers on the approval screen, and
// an absent one means the artifact cannot be checked against what was
// displayed. `to`, `value`, `data` and `accessList` have canonical absent
// forms — contract creation, zero, "0x" and the empty list — so they are
// normalized on both sides instead.
const APPROVED_FIELDS = {
chainId: {
kind: "quantity",
label: "network",
message:
"The signed transaction is for a different network than the one that was approved.",
},
nonce: {
kind: "quantity",
label: "nonce",
message: "The signed transaction does not carry the approved nonce.",
},
gasLimit: {
kind: "quantity",
label: "gas limit",
message:
"The signed transaction does not carry the approved gas limit.",
},
gasPrice: {
kind: "quantity",
label: "gas price",
message:
"The signed transaction does not carry the approved gas price.",
},
maxFeePerGas: {
kind: "quantity",
label: "maximum fee per gas",
message:
"The signed transaction does not carry the approved maximum fee per gas.",
},
maxPriorityFeePerGas: {
kind: "quantity",
label: "maximum priority fee per gas",
message:
"The signed transaction does not carry the approved maximum priority fee per gas.",
},
to: {
kind: "address",
label: "recipient",
message:
"The signed transaction does not go to the approved recipient.",
},
value: {
kind: "value",
label: "value",
message: "The signed transaction does not carry the approved value.",
},
data: {
kind: "data",
label: "call data",
message:
"The signed transaction does not carry the approved call data.",
},
accessList: {
kind: "accessList",
label: "access list",
message:
"The signed transaction does not carry the approved access list.",
},
};
// Compare one field of the artifact with the approved transaction. A field
// with no entry in the table above is refused rather than skipped: the loop
// below runs over the fields the type serializes, so an unmatched key means
// something that gets signed has no comparator at all.
function assertFieldMatches(key, parsed, approvedTx) {
const field = APPROVED_FIELDS[key];
if (!field) {
throw refuse(
"The signed transaction carries a field this wallet cannot compare with the approval.",
);
}
switch (field.kind) {
case "quantity": {
if (!present(approvedTx[key])) {
throw refuse(
"The approved transaction fixes no " +
field.label +
", so the signed transaction cannot be checked" +
" against what was shown.",
);
}
const approved = normalizeQuantity(approvedTx[key], field.label);
if (normalizeQuantity(parsed[key], field.label) !== approved) {
throw refuse(field.message);
}
return;
}
case "address":
if (!sameAddress(parsed[key], approvedTx[key])) {
throw refuse(field.message);
}
return;
case "value":
if (normalizeValue(parsed[key]) !== normalizeValue(approvedTx[key]))
throw refuse(field.message);
return;
case "data":
if (normalizeData(parsed[key]) !== normalizeData(approvedTx[key]))
throw refuse(field.message);
return;
default:
if (
normalizeAccessList(parsed[key]) !==
normalizeAccessList(approvedTx[key])
) {
throw refuse(field.message);
}
}
}
// The ceilings, applied to a transaction that is either about to be displayed
// or about to be broadcast. See MAX_GAS_LIMIT above for what they are for:
// they bound what the RPC node can talk this wallet into showing the user,
// which is the one thing comparing the artifact with the screen cannot do.
function assertWithinCeilings(tx) {
if (
present(tx.gasLimit) &&
normalizeQuantity(tx.gasLimit, "gas limit") > MAX_GAS_LIMIT
) {
throw refuse(
"The signed transaction sets a gas limit no network this wallet supports can accept.",
);
}
for (const key of ["gasPrice", "maxFeePerGas", "maxPriorityFeePerGas"]) {
if (!present(tx[key])) continue;
if (normalizeQuantity(tx[key], "fee per gas") > MAX_FEE_PER_GAS) {
throw refuse(
"The signed transaction sets a fee per gas far above any plausible value.",
);
}
}
}
// Refuse a field only a transaction type this wallet does not sign can carry.
// The type allowlist keeps these unreachable in production, which is exactly
// what they are for; it also means nothing else exercises them, so this is
// exported and tested on its own rather than left to be believed.
function assertNoForbiddenFields(parsed) {
for (const field of FORBIDDEN_FIELDS) {
if (carriesValue(parsed[field.key])) throw refuse(field.message);
}
}
// Closing structural check. Rebuild the transaction from the fields the
// comparisons cover, and nothing else, then compare the unsigned bytes. Every
// field carried by the artifact but absent from the rebuild changes the
// serialization, so this refuses anything this module does not account for —
// including a field a future ethers learns to parse onto an allowed type —
// instead of waving it through by not naming it. Also exported for its own
// test: nothing reachable today can make the bytes differ.
function assertNothingUnchecked(parsed) {
let rebuilt;
try {
const fields = { type: parsed.type };
for (const key of SERIALIZED_FIELDS[parsed.type]) {
fields[key] = parsed[key];
}
rebuilt = Transaction.from(fields);
} catch {
throw refuse(
"The signed transaction could not be rebuilt from the fields that were checked, so it cannot be shown to be the approved transaction.",
);
}
if (rebuilt.unsignedSerialized !== parsed.unsignedSerialized) {
throw refuse(
"The signed transaction carries data beyond the fields that were checked against the approval.",
);
}
}
// The other half of the closing check, and the one that makes it bind on the
// bytes that actually leave: every comparison above runs against the decode,
// so on its own the rebuild proves only that the transaction ethers understood
// is the approved one. What the background hands to broadcastTransaction() is
// the artifact string itself. Requiring the artifact to be exactly the
// canonical re-encoding of its own decode closes the gap between the two —
// no encoding the decoder normalizes away (a leading zero byte on an RLP
// quantity, say) can differ from what was checked. Hex case is not part of the
// encoding, so only that is normalized before comparing.
function assertCanonicalBytes(parsed, rawSignedTx) {
if (parsed.serialized !== String(rawSignedTx).toLowerCase()) {
throw refuse(
"The signed transaction is not encoded canonically, so the bytes that would be broadcast are not the bytes that were checked.",
);
}
}
// Assert that a raw signed transaction is the transaction the user approved, // Assert that a raw signed transaction is the transaction the user approved,
// signed by the address the approval was raised for, on the network that is // signed by the address the approval was raised for. Returns the parsed
// selected. Returns the parsed ethers Transaction on success, throws // ethers Transaction on success, throws otherwise.
// otherwise. function verifySignedTx(rawSignedTx, txParams, expectedFrom) {
//
// `approvedTx` is the populated transaction the approval screen displayed, and
// `expectedFrom` is the address that was active when the approval was raised —
// not whichever address is active now. An address switch between approval and
// signing therefore refuses here rather than producing a transaction from an
// account the approval did not name.
function verifySignedTx(
rawSignedTx,
approvedTx,
expectedFrom,
selectedChainId,
) {
if (typeof rawSignedTx !== "string" || !rawSignedTx.startsWith("0x")) { if (typeof rawSignedTx !== "string" || !rawSignedTx.startsWith("0x")) {
throw refuse("The signed transaction is missing or malformed."); throw new Error("The signed transaction is missing or malformed.");
}
// Nothing to compare against is a refusal like any other: an approval that
// does not carry the transaction it displayed cannot vouch for one.
if (!approvedTx || typeof approvedTx !== "object") {
throw refuse(
"There is no approved transaction to check the signed transaction against.",
);
} }
let parsed; let parsed;
try { try {
parsed = Transaction.from(rawSignedTx); parsed = Transaction.from(rawSignedTx);
} catch { } catch {
throw refuse("The signed transaction could not be decoded."); throw new Error("The signed transaction could not be decoded.");
} }
if (!parsed.from) { if (!parsed.from) {
throw refuse("The signed transaction carries no valid signature."); throw new Error("The signed transaction carries no valid signature.");
} }
if (!sameAddress(parsed.from, expectedFrom)) { if (!sameAddress(parsed.from, expectedFrom)) {
throw refuse( throw new Error(
"The signed transaction was signed by a different address than the one that was approved.", "The signed transaction was signed by a different address than the one that was approved.",
); );
} }
if (!sameAddress(parsed.to, txParams.to)) {
// Before any field is looked at: the type decides which fields exist at throw new Error(
// all, so an unrecognised type is refused outright rather than compared "The signed transaction does not go to the approved recipient.",
// field by field against an approval that cannot describe it.
if (!ALLOWED_TX_TYPES.includes(parsed.type)) {
throw refuse(
"The signed transaction is of a type this wallet does not sign, so what it would do beyond the approved transfer cannot be checked.",
); );
} }
assertNoForbiddenFields(parsed); if (normalizeValue(parsed.value) !== normalizeValue(txParams.value)) {
throw new Error(
// The selected network, not the artifact, is the authority on which chain "The signed transaction does not carry the approved value.",
// this may be broadcast to; without it nothing can be verified.
if (!present(selectedChainId)) {
throw refuse(
"The selected network is unknown, so the signed transaction cannot be checked against it.",
); );
} }
if (parsed.chainId !== normalizeQuantity(selectedChainId, "network")) { if (normalizeData(parsed.data) !== normalizeData(txParams.data)) {
throw refuse( throw new Error(
"The signed transaction is for a different network than the one that is selected.", "The signed transaction does not carry the approved call data.",
); );
} }
// The approved fee mechanism, named before the type comparison below
// subsumes it: the fee the user agreed to is only meaningful under the
// mechanism it was quoted in, and saying so is more use than "a different
// transaction type".
const approvedEip1559 =
present(approvedTx.maxFeePerGas) ||
present(approvedTx.maxPriorityFeePerGas);
const approvedLegacy = present(approvedTx.gasPrice);
const signedEip1559 = parsed.type === 2;
if (
(approvedEip1559 && !signedEip1559) ||
(approvedLegacy && signedEip1559)
) {
throw refuse(
"The signed transaction does not use the approved fee mechanism.",
);
}
// The type decides which fields are compared, so it is compared first and
// against the approval, not merely checked for membership of the
// allowlist above.
if (!present(approvedTx.type)) {
throw refuse(
"The approved transaction fixes no transaction type, so the signed transaction cannot be checked against what was shown.",
);
}
if (
BigInt(parsed.type) !==
normalizeQuantity(approvedTx.type, "transaction type")
) {
throw refuse(
"The signed transaction does not use the approved transaction type.",
);
}
// Every field this type serializes, compared with the transaction the user
// was shown. Driving the loop off SERIALIZED_FIELDS is what keeps this
// exhaustive: the same table decides what assertNothingUnchecked() rebuilds
// from, so a field that gets signed and is not compared here cannot exist.
for (const key of SERIALIZED_FIELDS[parsed.type]) {
assertFieldMatches(key, parsed, approvedTx);
}
assertWithinCeilings(parsed);
assertNothingUnchecked(parsed);
assertCanonicalBytes(parsed, rawSignedTx);
return parsed; return parsed;
} }
@@ -562,7 +91,7 @@ function verifySignedTx(
// address on success, throws otherwise. // address on success, throws otherwise.
function verifySignature(signParams, signature, expectedFrom) { function verifySignature(signParams, signature, expectedFrom) {
if (typeof signature !== "string" || !signature.startsWith("0x")) { if (typeof signature !== "string" || !signature.startsWith("0x")) {
throw refuse("The signature is missing or malformed."); throw new Error("The signature is missing or malformed.");
} }
let recovered; let recovered;
@@ -580,11 +109,11 @@ function verifySignature(signParams, signature, expectedFrom) {
recovered = verifyTypedData(domain, types, message, signature); recovered = verifyTypedData(domain, types, message, signature);
} }
} catch { } catch {
throw refuse("The signature could not be verified."); throw new Error("The signature could not be verified.");
} }
if (!sameAddress(recovered, expectedFrom)) { if (!sameAddress(recovered, expectedFrom)) {
throw refuse( throw new Error(
"The signature was produced by a different address than the one that was approved.", "The signature was produced by a different address than the one that was approved.",
); );
} }
@@ -592,100 +121,4 @@ function verifySignature(signParams, signature, expectedFrom) {
return recovered; return recovered;
} }
// The stage a transaction approval failed at. Which stage it is decides module.exports = { verifySignedTx, verifySignature, sameAddress };
// whether the approval survives the failure.
const TX_STAGE_SIGN = "sign";
const TX_STAGE_VERIFY = "verify";
const TX_STAGE_BROADCAST = "broadcast";
// Not a failure of this request at all: a second response arrived for an
// approval an attempt already holds. The first attempt is still running and
// may yet succeed, so the one thing the popup must not say is "start again
// from the site".
const TX_STAGE_INFLIGHT = "inflight";
function errorText(err) {
if (typeof err === "string" && err !== "") return err;
if (err && (err.shortMessage || err.message)) {
return err.shortMessage || err.message;
}
return "The transaction could not be sent.";
}
// What the background does with a pending transaction approval after a failed
// attempt: what it tells the popup, and whether the approval is spent
// (resolved to the requesting page as an error and deleted) or left standing
// so the user can try the transaction they already saw again.
//
// - sign: the popup could not produce an artifact, almost always a wrong
// password. Nothing left the extension, so the approval stands.
// - verify: a mismatch is a refusal and spends the approval — an artifact
// that is not the approved transaction must never be retried against that
// approval. Anything else failed before the check ran and is retryable.
// - broadcast: always terminal. A broadcast that throws after the node
// accepted the transaction is routine (a timeout, a dropped response, a
// node answering "already known"), so the wallet cannot tell a transaction
// that never left from one that is already in the mempool. The approval is
// spent and the requesting page has been given its outcome; a second
// attempt against it would report a second outcome for one request.
function describeTxFailure(stage, err) {
const error = errorText(err);
const retryable =
stage === TX_STAGE_SIGN ||
(stage === TX_STAGE_VERIFY && failureIsRetryable(err));
return { error, retryable, spendApproval: !retryable };
}
// What the popup shows and does after the background reports a failed signing
// attempt. A retryable failure leaves the approval pending in the background,
// so the button goes back to being usable; a refusal spent the approval, and
// the popup says so rather than offering a button that cannot succeed.
//
// A failed broadcast gets its own wording: the transaction may already be on
// the network, so telling the user to start again from the site is exactly the
// wrong instruction.
function describeSigningFailure(response, fallbackMessage) {
let message = (response && response.error) || fallbackMessage;
if (!/[.!?]$/.test(message)) message += ".";
const retryable = !!(response && response.retryable);
const stage = response && response.stage;
if (!retryable) {
if (stage === TX_STAGE_BROADCAST) {
message +=
" The transaction may still have reached the network." +
" Check the account before sending it again.";
} else if (stage === TX_STAGE_INFLIGHT) {
message +=
" The first attempt is still running and may still succeed." +
" Wait for it rather than starting again.";
} else {
message +=
" This request can no longer be signed. Please start it" +
" again from the site.";
}
}
return { message, retryable };
}
module.exports = {
verifySignedTx,
verifySignature,
assertNoForbiddenFields,
assertNothingUnchecked,
assertCanonicalBytes,
assertWithinCeilings,
sameAddress,
failureIsRetryable,
describeTxFailure,
describeSigningFailure,
ApprovalMismatchError,
ALLOWED_TX_TYPES,
SERIALIZED_FIELDS,
FORBIDDEN_FIELDS,
APPROVED_FIELDS,
TX_STAGE_SIGN,
TX_STAGE_VERIFY,
TX_STAGE_BROADCAST,
TX_STAGE_INFLIGHT,
MAX_GAS_LIMIT,
MAX_FEE_PER_GAS,
};

View File

@@ -11,9 +11,8 @@ const {
const { ERC20_ABI } = require("./constants"); const { ERC20_ABI } = require("./constants");
const { log, debugFetch } = require("./log"); const { log, debugFetch } = require("./log");
const { deriveAddressFromXpub } = require("./wallet"); const { deriveAddressFromXpub } = require("./wallet");
const { TOKEN_BY_ADDRESS } = require("./tokenList"); const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList");
const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders"); const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
const { isSpoofedSymbol } = require("./symbolSpoof");
// Use a static network to skip auto-detection (which can fail and cause // Use a static network to skip auto-detection (which can fail and cause
// "could not coalesce error" on some RPC endpoints like Cloudflare). // "could not coalesce error" on some RPC endpoints like Cloudflare).
@@ -66,12 +65,7 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
const balances = []; const balances = [];
for (const item of items) { for (const item of items) {
// Case-insensitive: the token type is an explorer's label, not a if (item.token?.type !== "ERC-20") continue;
// protocol value, and an exact comparison silently drops a real
// holding if one ever writes "erc-20". Which types are admitted
// is unchanged.
const type = String(item.token?.type || "").toUpperCase();
if (type !== "ERC-20") continue;
const decimals = parseInt(item.token.decimals || "18", 10); const decimals = parseInt(item.token.decimals || "18", 10);
const bal = formatTokenBalance(item.value || "0", decimals); const bal = formatTokenBalance(item.value || "0", decimals);
if (bal === "0.0") continue; if (bal === "0.0") continue;
@@ -95,11 +89,15 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
// Skip spam tokens the user never asked to see // Skip spam tokens the user never asked to see
if (!isKnown && !isTracked && !hasEnoughHolders) continue; if (!isKnown && !isTracked && !hasEnoughHolders) continue;
// Skip tokens spoofing a known symbol from a different address. // Skip tokens spoofing a known symbol from a different address
// Every row here is an ERC-20 the explorer reported, so it has a const sym = (item.token.symbol || "").toUpperCase();
// contract address; the native ETH balance is fetched over RPC in const legitAddr = KNOWN_SYMBOLS.get(sym);
// refreshBalances and never passes through this loop. if (
if (isSpoofedSymbol(item.token.symbol, tokenAddr)) continue; legitAddr !== undefined &&
legitAddr !== null &&
tokenAddr !== legitAddr
)
continue;
balances.push({ balances.push({
address: item.token.address_hash, address: item.token.address_hash,

View File

@@ -1,245 +0,0 @@
// The one place in this tree that names `browser` or `chrome`.
//
// The two targets do not agree on either the namespace or the call shape.
// Chrome MV3 exposes `chrome.*`, where tabs, windows and messaging take a
// trailing callback and report failure through the global
// `chrome.runtime.lastError`. Firefox MV2 exposes `browser.*`, where those
// same methods return promises and take no callback at all — a function
// passed where an options argument is expected is simply never invoked, so
// the call looks like it succeeded and silently never completes. Resolving
// the namespace with a ternary and then calling it Chrome-style, which is
// what this codebase used to do, is broken on Firefox in exactly that way:
// see https://git.eeqj.de/sneak/AutistMask/issues/153.
//
// The strategy is promises out, everywhere. Callers `await`; nothing outside
// this file has to know which browser it is running on.
//
// Two deliberate asymmetries, because they are what the browsers actually do
// rather than what a uniform-looking shim would pretend:
//
// - Storage is called in its PROMISE form on both namespaces.
// `chrome.storage.local.get()` returns a promise on MV3 and the popup
// already depends on that — src/shared/state.js has always awaited it, and
// that is precisely why the Firefox popup flows work today while
// everything in the issue above does not. Wrapping it in a callback here
// would be a change, not a fix.
// - notify() sends without a callback. It is for a message whose answer
// nobody reads; appending a callback would only manufacture a
// lastError/rejection for a receiver that was never expected to reply.
//
// Everything is resolved on use rather than captured at module load. The MV3
// service worker is torn down and re-evaluated repeatedly, and the unit
// suite installs its stubs on `global.chrome` around a require().
// The extension API namespace, preferring `browser.*` where it exists.
//
// Whole-namespace, never per-method: mixing `browser.tabs` with
// `chrome.windows` would also mix promise and callback semantics inside a
// single call path, which is the bug this module exists to remove.
function extensionApi() {
if (typeof browser !== "undefined" && browser) return browser;
if (typeof chrome !== "undefined" && chrome) return chrome;
return null;
}
// True when the resolved namespace is the promise-flavoured one.
//
// It doubles as "this is the Gecko/MV2 build", which is a second question
// with the same answer and one real caller: src/content/index.js has to
// inject the inpage provider itself there, because MV2 has no
// `"world": "MAIN"` for a manifest-declared content script.
function hasBrowserNamespace() {
return typeof browser !== "undefined" && !!browser;
}
function namespaceMember(name) {
const api = extensionApi();
return (api && api[name]) || null;
}
function runtimeApi() {
return namespaceMember("runtime");
}
function tabsApi() {
return namespaceMember("tabs");
}
function windowsApi() {
return namespaceMember("windows");
}
function alarmsApi() {
return namespaceMember("alarms");
}
// The toolbar button. MV3 calls it `action`, MV2 calls it `browserAction`.
function actionApi() {
const api = extensionApi();
if (!api) return null;
return api.action || api.browserAction || null;
}
// `storage.local`, or null in a context that has no storage permission. Null
// rather than a throw because two callers degrade rather than fail on it.
function storageLocal() {
const storage = namespaceMember("storage");
return (storage && storage.local) || null;
}
// The Chrome-only error channel. Never populated for a `browser.*` call,
// which is why the checks that used to guard callbacks in the background are
// gone: on this side it becomes a rejection, and on the other side there was
// never anything to read.
function lastError() {
const runtime = runtimeApi();
return (runtime && runtime.lastError) || null;
}
// Call `owner[method](...args)` and return a promise for its result.
//
// On the promise namespace the method already returns one. On the callback
// namespace the callback is appended here and lastError becomes a rejection,
// because a caller holding a promise has nowhere to check a global flag.
function invoke(owner, method, ...args) {
if (!owner || typeof owner[method] !== "function") {
return Promise.reject(
new Error(
"extension API " +
method +
"() is not available in this context",
),
);
}
if (hasBrowserNamespace()) {
try {
return Promise.resolve(owner[method](...args));
} catch (e) {
return Promise.reject(e);
}
}
return new Promise((resolve, reject) => {
owner[method](...args, (result) => {
const err = lastError();
if (err) reject(new Error(err.message || String(err)));
else resolve(result);
});
});
}
/**
* Send a message to the extension's own contexts and resolve with the reply.
*
* Rejects when nothing is listening, on both browsers. A caller that does not
* care must say so — see notify().
*
* @param {Object} message
* @returns {Promise<*>} the receiver's response.
*/
function sendMessage(message) {
return invoke(runtimeApi(), "sendMessage", message);
}
/**
* Send a message nobody is expected to answer, and swallow the fact that
* nobody did.
*
* @param {Object} message
* @returns {void}
*/
function notify(message) {
const runtime = runtimeApi();
if (!runtime || typeof runtime.sendMessage !== "function") return;
const result = runtime.sendMessage(message);
// MV3 hands back a promise for a one-argument send, and it rejects when
// the background is not listening. Unhandled, that surfaces as an error
// the e2e suites fail the run on.
if (result && typeof result.catch === "function") result.catch(() => {});
}
/**
* @param {string|string[]|Object} keys
* @returns {Promise<Object>} the stored items, or {} where storage is absent.
*/
function storageGet(keys) {
const storage = storageLocal();
if (!storage) return Promise.resolve({});
return Promise.resolve(storage.get(keys));
}
/**
* @param {Object} items
* @returns {Promise<void>}
*/
function storageSet(items) {
const storage = storageLocal();
if (!storage) return Promise.resolve();
return Promise.resolve(storage.set(items));
}
/**
* @param {Object} queryInfo
* @returns {Promise<Array>} the matching tabs.
*/
function tabsQuery(queryInfo) {
return invoke(tabsApi(), "query", queryInfo);
}
/**
* Send a message to one tab's content script.
*
* Rejects for a tab that has no receiver, which is most of them. That
* rejection is the promise-shaped replacement for the runtime.lastError
* checks the broadcast helpers used to make, and callers ignore it the same
* way.
*
* @param {number} tabId
* @param {Object} message
* @returns {Promise<*>}
*/
function tabsSendMessage(tabId, message) {
return invoke(tabsApi(), "sendMessage", tabId, message);
}
/**
* @param {Object} createData
* @returns {Promise<Object>} the created window.
*/
function windowsCreate(createData) {
return invoke(windowsApi(), "create", createData);
}
/**
* @returns {Promise<Object>} the last focused window.
*/
function windowsGetLastFocused() {
return invoke(windowsApi(), "getLastFocused");
}
/**
* @param {number} windowId
* @returns {Promise<void>}
*/
function windowsRemove(windowId) {
return invoke(windowsApi(), "remove", windowId);
}
module.exports = {
actionApi,
alarmsApi,
extensionApi,
hasBrowserNamespace,
notify,
runtimeApi,
sendMessage,
storageGet,
storageLocal,
storageSet,
tabsApi,
tabsQuery,
tabsSendMessage,
windowsApi,
windowsCreate,
windowsGetLastFocused,
windowsRemove,
};

View File

@@ -18,7 +18,6 @@
// its own refresh — see updatePhishingList(). // its own refresh — see updatePhishingList().
const vendoredConfig = require("./phishingBlocklist.json"); const vendoredConfig = require("./phishingBlocklist.json");
const { storageLocal } = require("./browserApi");
const BLOCKLIST_URL = const BLOCKLIST_URL =
"https://raw.githubusercontent.com/MetaMask/eth-phishing-detect/main/src/config.json"; "https://raw.githubusercontent.com/MetaMask/eth-phishing-detect/main/src/config.json";
@@ -47,10 +46,18 @@ let lastAttemptTime = 0;
let fetchPromise = null; let fetchPromise = null;
let loadPromise = null; let loadPromise = null;
// storageLocal() resolves on use rather than at module load, so a test can // Resolved on use rather than captured at module load, so a test can install
// install a stub after requiring this module, and it returns null where the // a stub after requiring the module and so the popup — which has no reason to
// API is absent — which is why the popup, with no reason to touch the delta, // touch the delta — does not fail to load where the API is absent.
// loads fine without it. function storageApi() {
if (typeof browser !== "undefined" && browser.storage) {
return browser.storage.local;
}
if (typeof chrome !== "undefined" && chrome.storage) {
return chrome.storage.local;
}
return null;
}
/** /**
* Sanitise a timestamp read back from storage. * Sanitise a timestamp read back from storage.
@@ -79,7 +86,7 @@ function sanitizeTimestamp(value) {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async function loadDeltaFromStorage() { async function loadDeltaFromStorage() {
const storage = storageLocal(); const storage = storageApi();
if (!storage) return; if (!storage) return;
try { try {
const result = await storage.get(DELTA_STORAGE_KEY); const result = await storage.get(DELTA_STORAGE_KEY);
@@ -115,7 +122,7 @@ function ensureDeltaLoaded() {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async function saveDeltaToStorage() { async function saveDeltaToStorage() {
const storage = storageLocal(); const storage = storageApi();
if (!storage) return; if (!storage) return;
try { try {
const data = { const data = {

View File

@@ -2,10 +2,11 @@
const { DEFAULT_RPC_URL, DEFAULT_BLOCKSCOUT_URL } = require("./constants"); const { DEFAULT_RPC_URL, DEFAULT_BLOCKSCOUT_URL } = require("./constants");
const { networkById } = require("./networks"); const { networkById } = require("./networks");
// Dependency-free constant module; safe to pull into a background bundle.
const { RESTORABLE_VIEWS } = require("../popup/restorableViews");
const { storageGet, storageSet } = require("./browserApi"); const storageApi =
typeof browser !== "undefined"
? browser.storage.local
: chrome.storage.local;
const DEFAULT_STATE = { const DEFAULT_STATE = {
hasWallet: false, hasWallet: false,
@@ -42,39 +43,6 @@ const state = {
viewStack: [], viewStack: [],
}; };
// Keep only the leading run of stored views the popup is willing to render.
//
// restoreView() refuses to reopen ONTO a non-restorable view, but the stack
// behind it used to be restored verbatim, so Back could walk onto a screen
// whose content is deliberately never re-rendered — and "show-phrase" has no
// Back control to leave by. Truncating at the first such entry instead of
// splicing it out keeps the result a prefix of the stored stack, so every
// surviving entry's Back target is exactly the one it had; splicing would
// silently re-point the entry above the hole at a different screen.
//
// Filtering happens here on load rather than in saveState(): the live
// in-session stack is legitimate (the screen really is rendered while the
// popup is open), and only a load-side filter also repairs the stacks
// already in storage, including ones written before a view left the set.
function restorableStack(stored, currentView) {
// A stored stack that is missing or not an array keeps nothing, but it
// still goes through the never-empty rule below rather than returning
// early: otherwise a corrupt stack would depend on exactly the goBack()
// fallback that the explicit ["main"] exists in order not to depend on.
const source = Array.isArray(stored) ? stored : [];
const cut = source.findIndex((view) => !RESTORABLE_VIEWS.has(view));
const kept = cut === -1 ? source.slice() : source.slice(0, cut);
// A view restored below the root still needs somewhere for Back to go.
if (
kept.length === 0 &&
currentView !== "main" &&
RESTORABLE_VIEWS.has(currentView)
) {
return ["main"];
}
return kept;
}
// Return the network configuration for the currently selected network. // Return the network configuration for the currently selected network.
function currentNetwork() { function currentNetwork() {
return networkById(state.networkId); return networkById(state.networkId);
@@ -111,11 +79,11 @@ async function saveState() {
viewData: state.viewData, viewData: state.viewData,
viewStack: state.viewStack, viewStack: state.viewStack,
}; };
await storageSet({ autistmask: persisted }); await storageApi.set({ autistmask: persisted });
} }
async function loadState() { async function loadState() {
const result = await storageGet("autistmask"); const result = await storageApi.get("autistmask");
if (result.autistmask) { if (result.autistmask) {
const saved = result.autistmask; const saved = result.autistmask;
state.wallets = saved.wallets || []; state.wallets = saved.wallets || [];
@@ -182,7 +150,7 @@ async function loadState() {
saved.selectedAddress !== undefined ? saved.selectedAddress : null; saved.selectedAddress !== undefined ? saved.selectedAddress : null;
state.selectedToken = saved.selectedToken || null; state.selectedToken = saved.selectedToken || null;
state.viewData = saved.viewData || {}; state.viewData = saved.viewData || {};
state.viewStack = restorableStack(saved.viewStack, state.currentView); state.viewStack = Array.isArray(saved.viewStack) ? saved.viewStack : [];
} }
} }

View File

@@ -1,108 +0,0 @@
// The known-symbol spoof rule, in one place.
//
// A token that borrows a known symbol from a contract that is not the one
// that symbol belongs to is a spoof, and the wallet hides it. Three surfaces
// ask that question — the transaction history, the Send token selector and
// the balance list — and they must answer it identically: a token the history
// calls fake while the balance list lists it as a holding is worse than
// either verdict alone, because the balance list is where the user forms
// their belief about what they own (issue #235).
//
// KNOWN_SYMBOLS maps a symbol to the set of lowercased contract addresses
// that may bear it, or to null. Null means the symbol belongs to the native
// asset, which has no contract at all, so no contract may bear it and every
// one that does is a spoof. "ETH" is the only such entry today; the rule is
// written so that a second one needs no change here or at any call site.
//
// The value is a set because a ticker is not unique: seven symbols in the
// bundled list belong to two real contracts each, and answering with one of
// them hid the other one's holders' money (issue #276). Membership, not
// equality, is therefore the question — but it is the same question, asked of
// a table that can now state the truth. Every address in a set is one the
// wallet ships as a real token; a contract outside the set is still a spoof.
//
// The symbol is attacker-controlled — it is whatever the ERC-20 contract
// returns — so the lookup is done on a normalized form (issue #260): the
// question is whether the symbol reaches the user's eye as a known one,
// since that is what the user acts on.
const { KNOWN_SYMBOLS } = require("./tokenList");
// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
// over the address, not part of its identity.
function normalizeAddress(addr) {
return (addr || "").toLowerCase();
}
// Fold a symbol onto what a user actually sees, and no further:
//
// NFKC collapses compatibility variants that render as the ASCII
// letters they imitate — fullwidth ETH, styled mathematical
// letters — and maps the non-ASCII spaces onto U+0020.
// strip drops what paints nothing: \p{Cf} plus
// \p{Default_Ignorable_Code_Point} plus U+007F. That covers
// the format characters (zero-width space, joiner and
// non-joiner, word joiner, soft hyphen, byte-order mark, bidi
// marks and overrides), the variation selectors, the Hangul
// fillers, and DELETE. Removed everywhere, not merely at the
// ends.
// trim removes surrounding whitespace, which HTML collapses:
// `" ETH "` is painted next to the user's real ETH as `ETH`.
// toUpperCase makes the comparison case-insensitive, as before.
//
// The rule is "strip what paints nothing". The Unicode classes are how
// that is spelled, not what it means, which is why U+007F is named on its
// own: it is a control rather than a default-ignorable character, so no
// class here reaches it, yet it paints nothing all the same. Measured in
// the repo's pinned e2e Chromium (16px sans-serif, plain `ETH` = 32.00px,
// so an invisible prefix leaves 32.00px):
//
// U+007F, U+3164, U+115F, U+FE0F, U+FE00 32.00px — invisible
// U+FFA0 40.00px — a box
// U+1160 48.00px — a box
// U+0001, U+0085, U+0090 48.00px — a box
//
// U+1160 and U+FFA0 are `Default_Ignorable_Code_Point` members that font
// fallback nonetheless draws, and they are stripped anyway: erring toward
// hiding a token that does not look like `ETH` is the harmless direction of
// the two. The other controls are left alone for the same reason read the
// other way — a symbol carrying a visible box does not reach the eye as
// `ETH`, so filtering it would hide a token the user could not have
// confused with the native asset.
//
// Deliberately not folded, and asserted as open in tests/symbolSpoof.test.js:
// interior whitespace (`E T H` renders as `E T H`, so folding it would filter
// a token nobody could confuse with the native asset), confusables that are
// distinct letters rather than compatibility variants (Cyrillic capital Ie,
// U+0415; Greek capital Epsilon, U+0395), bidi reordering, which needs the
// bidi algorithm rather than a character filter, and the visible controls.
//
// This decides only how the question is asked. Nothing here changes what a
// surface displays; a token still shows the symbol it reports.
function normalizeSymbol(symbol) {
return String(symbol || "")
.normalize("NFKC")
.replace(/[\p{Cf}\p{Default_Ignorable_Code_Point}\x7F]/gu, "")
.trim()
.toUpperCase();
}
// True when a token bearing `symbol` from contract `contractAddress` is
// impersonating a known symbol.
//
// An empty contract address is the native asset, which is never a spoof:
// this is what keeps the user's real ETH out of the rule, and it holds for
// any symbol that becomes null-mapped later, not just for ETH.
function isSpoofedSymbol(symbol, contractAddress) {
const contract = normalizeAddress(contractAddress);
if (!contract) return false;
const sym = normalizeSymbol(symbol);
if (!KNOWN_SYMBOLS.has(sym)) return false;
const legit = KNOWN_SYMBOLS.get(sym);
if (legit === null) return true;
return !legit.has(contract);
}
module.exports = {
isSpoofedSymbol,
};

View File

@@ -3607,33 +3607,14 @@ for (const t of TOKENS) {
TOKEN_BY_ADDRESS.set(t.address.toLowerCase(), t); TOKEN_BY_ADDRESS.set(t.address.toLowerCase(), t);
} }
// Build a map of symbol (uppercased) -> the set of contract addresses // Build a map of symbol (uppercased) -> legitimate contract address (lowercased).
// (lowercased) that legitimately bear it. Used for spoofed-symbol detection. // Used for spoofed-symbol detection. "ETH" maps to null (native token).
// "ETH" maps to null: the native asset has no contract, so no contract may
// bear its symbol.
//
// The value is a set and not a single address because tickers are not unique
// and the list above proves it: seven of these 512 tokens share a symbol with
// another entry — FRAX, REUSD, TON, EURE, MSUSD, MUSD and JPYC — at two
// different real contracts each, all of them from the same source fetch. A
// one-address-per-symbol table can only answer that by picking a winner, and
// the loser is then a token in our own bundled list that the spoof filter
// hides from the balance list, the history and the send selector at its own
// address, so the user cannot spend it (issue #276). Naming every address
// that bears the symbol is the only shape that says what is true; it does not
// loosen the rule, because a contract outside the set is still a spoof.
const KNOWN_SYMBOLS = new Map(); const KNOWN_SYMBOLS = new Map();
KNOWN_SYMBOLS.set("ETH", null); KNOWN_SYMBOLS.set("ETH", null);
for (const t of TOKENS) { for (const t of TOKENS) {
const upper = t.symbol.toUpperCase(); const upper = t.symbol.toUpperCase();
if (!KNOWN_SYMBOLS.has(upper)) { if (!KNOWN_SYMBOLS.has(upper)) {
KNOWN_SYMBOLS.set(upper, new Set()); KNOWN_SYMBOLS.set(upper, t.address.toLowerCase());
}
const addresses = KNOWN_SYMBOLS.get(upper);
// A null entry is the native asset and stays null: an ERC-20 that reports
// the native symbol does not thereby become entitled to it.
if (addresses !== null) {
addresses.add(t.address.toLowerCase());
} }
} }

View File

@@ -8,9 +8,8 @@
const { formatEther, formatUnits } = require("ethers"); const { formatEther, formatUnits } = require("ethers");
const { log, debugFetch } = require("./log"); const { log, debugFetch } = require("./log");
const { TOKEN_BY_ADDRESS } = require("./tokenList"); const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList");
const { parseHoldersCount, isLowHolderCount } = require("./holders"); const { parseHoldersCount, isLowHolderCount } = require("./holders");
const { isSpoofedSymbol } = require("./symbolSpoof");
// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum // Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
// over the address, not part of its identity. Every address comparison in // over the address, not part of its identity. Every address comparison in
@@ -246,6 +245,18 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
return result; return result;
} }
// Check if a token transfer is spoofing a known symbol.
// Returns true if the symbol matches a known token but the contract
// address doesn't match the legitimate one.
function isSpoofedSymbol(tx) {
if (!tx.contractAddress) return false;
const symbol = (tx.symbol || "").toUpperCase();
if (!KNOWN_SYMBOLS.has(symbol)) return false;
const legit = KNOWN_SYMBOLS.get(symbol);
if (legit === null) return true; // "ETH" as ERC-20 is always fake
return normalizeAddress(tx.contractAddress) !== normalizeAddress(legit);
}
// Pure filter function. Takes raw transactions and filter settings, // Pure filter function. Takes raw transactions and filter settings,
// returns { transactions, newFraudContracts }. // returns { transactions, newFraudContracts }.
function filterTransactions(txs, filters = {}) { function filterTransactions(txs, filters = {}) {
@@ -272,7 +283,7 @@ function filterTransactions(txs, filters = {}) {
const contract = normalizeAddress(tx.contractAddress); const contract = normalizeAddress(tx.contractAddress);
// Filter spoofed known symbols and record the fraud contract // Filter spoofed known symbols and record the fraud contract
if (hideSpoofed && isSpoofedSymbol(tx.symbol, tx.contractAddress)) { if (hideSpoofed && isSpoofedSymbol(tx)) {
if (contract && !fraudSet.has(contract)) { if (contract && !fraudSet.has(contract)) {
fraudSet.add(contract); fraudSet.add(contract);
newFraud.push(contract); newFraud.push(contract);

View File

@@ -1,24 +1,5 @@
// Wallet and address deletion state transitions, kept out of the views so the // Wallet deletion state transition, kept out of the view so the selection
// selection and broadcast rules are testable without a DOM. // and broadcast rules are testable without a DOM.
const { notify } = require("./browserApi");
// Two records of the same address can be stored in different cases, so
// address equality is never a literal string comparison.
function sameAddress(a, b) {
if (a === null || a === undefined || b === null || b === undefined) {
return false;
}
return String(a).toLowerCase() === String(b).toLowerCase();
}
// Forget every site permission held against the given addresses.
function dropSitePermissions(state, addresses) {
for (const addr of addresses) {
delete state.allowedSites[addr];
delete state.deniedSites[addr];
}
}
// Remove wallet `walletIdx` from `state` and repair the derived state. // Remove wallet `walletIdx` from `state` and repair the derived state.
// //
@@ -37,13 +18,19 @@ function removeWalletFromState(state, walletIdx) {
const wallet = state.wallets[walletIdx]; const wallet = state.wallets[walletIdx];
const addresses = (wallet.addresses || []).map((a) => a.address); const addresses = (wallet.addresses || []).map((a) => a.address);
const previousActive = state.activeAddress; const previousActive = state.activeAddress;
const activeWasDeleted = addresses.some((a) => const activeWasDeleted =
sameAddress(a, previousActive), previousActive !== null &&
previousActive !== undefined &&
addresses.some(
(a) => a.toLowerCase() === String(previousActive).toLowerCase(),
); );
state.wallets.splice(walletIdx, 1); state.wallets.splice(walletIdx, 1);
dropSitePermissions(state, addresses); for (const addr of addresses) {
delete state.allowedSites[addr];
delete state.deniedSites[addr];
}
state.hasWallet = state.wallets.length > 0; state.hasWallet = state.wallets.length > 0;
@@ -71,87 +58,13 @@ function removeWalletFromState(state, walletIdx) {
return { activeAddressChanged: state.activeAddress !== previousActive }; return { activeAddressChanged: state.activeAddress !== previousActive };
} }
// Whether a wallet may be offered a per-address remove control, and the same
// gate the removal itself is held behind.
//
// Only a wallet that derives its addresses from an extended key can hold more
// than one, so only those get the control — a key wallet has exactly one
// address and no "+" button either. The last address of any wallet is never
// removable: a wallet with no addresses is what delete-wallet is for.
function canRemoveAddress(wallet) {
if (!wallet) return false;
if (wallet.type !== "hd" && wallet.type !== "xprv") return false;
return (wallet.addresses || []).length > 1;
}
// Remove address `addrIdx` of wallet `walletIdx` and repair the derived state.
//
// Nothing is destroyed here. The address stays derivable from the wallet's own
// key material and any funds at it are untouched; this only stops the wallet
// tracking it. `nextIndex` is deliberately left alone — it is a derivation
// high-water mark, so "+" derives a fresh index rather than handing back the
// address just removed, and the gap it leaves is within what
// `scanForAddresses()` re-discovers on a later import.
//
// The rules mirror removeWalletFromState() one level down:
// - The call is refused unless canRemoveAddress() allows it, so the last
// address of a wallet always survives.
// - Site permissions are dropped for the removed address.
// - `selectedAddress` follows the splice, but only within the wallet that
// lost the address: it is decremented when an earlier address was
// removed, and falls back to that wallet's first address when the
// selection itself was removed. `selectedWallet` never moves, because the
// wallet list does not.
// - `activeAddress` moves only when it was the removed address, and then to
// the wallet's first remaining address.
//
// Returns whether the address was removed and whether `activeAddress`
// changed, so the caller can broadcast it.
function removeAddressFromState(state, walletIdx, addrIdx) {
const wallet = state.wallets[walletIdx];
const refused = { removed: false, activeAddressChanged: false };
if (!canRemoveAddress(wallet)) return refused;
if (!wallet.addresses[addrIdx]) return refused;
const address = wallet.addresses[addrIdx].address;
const previousActive = state.activeAddress;
const activeWasRemoved = sameAddress(address, previousActive);
wallet.addresses.splice(addrIdx, 1);
dropSitePermissions(state, [address]);
if (state.selectedWallet === walletIdx) {
if (state.selectedAddress === addrIdx) {
state.selectedAddress = 0;
} else if (
typeof state.selectedAddress === "number" &&
state.selectedAddress > addrIdx
) {
state.selectedAddress -= 1;
}
}
if (activeWasRemoved) {
state.activeAddress = wallet.addresses[0].address;
}
return {
removed: true,
activeAddressChanged: state.activeAddress !== previousActive,
};
}
// Tell the background the active address changed, so it re-emits // Tell the background the active address changed, so it re-emits
// accountsChanged to connected sites. Same call shape as the address // accountsChanged to connected sites. Same call shape as the address
// switch in the home view. // switch in the home view.
function broadcastActiveChanged() { function broadcastActiveChanged() {
notify({ type: "AUTISTMASK_ACTIVE_CHANGED" }); const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
runtime.sendMessage({ type: "AUTISTMASK_ACTIVE_CHANGED" });
} }
module.exports = { module.exports = { removeWalletFromState, broadcastActiveChanged };
canRemoveAddress,
removeAddressFromState,
removeWalletFromState,
broadcastActiveChanged,
};

View File

@@ -1,309 +0,0 @@
// Preparation of the transaction the approval screen displays.
//
// This is the half of the fix that makes the verification in
// approvalVerify.test.js mean anything: the numbers the user reads have to be
// produced before the screen is drawn and be the numbers that get signed. What
// is asserted here is that the object leaving this module is complete (nothing
// is left for the popup to fill in), that it survives the messaging boundary
// (extension messaging is JSON, which has no bigint), and that nothing the
// requesting page or the RPC node can say turns it into an approval that
// should never have been raised.
const { Network, Wallet } = require("ethers");
const {
prepareApprovalTx,
serializeApprovedTx,
POPULATE_TIMEOUT_MS,
} = require("../src/shared/approvalTx");
const {
SERIALIZED_FIELDS,
MAX_FEE_PER_GAS,
MAX_GAS_LIMIT,
} = require("../src/shared/approvalVerify");
const SIGNER_KEY =
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
const signer = new Wallet(SIGNER_KEY);
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// The ordinary dApp request: recipient, value, call data, and nothing else.
const TX_PARAMS = {
from: signer.address,
to: RECIPIENT,
value: "0x2386f26fc10000",
data: "0xdeadbeef",
};
function providerWith(overrides) {
return {
getNetwork: async () => Network.from(1),
getTransactionCount: async () => 7,
estimateGas: async () => 21000n,
getFeeData: async () => ({
gasPrice: 2000000000n,
maxFeePerGas: 2000000000n,
maxPriorityFeePerGas: 1000000000n,
}),
...(overrides || {}),
};
}
// A node that only quotes a flat gas price, so populateTransaction produces a
// legacy transaction rather than an EIP-1559 one.
const legacyProvider = providerWith({
getFeeData: async () => ({
gasPrice: 2000000000n,
maxFeePerGas: null,
maxPriorityFeePerGas: null,
}),
});
describe("prepareApprovalTx", () => {
test("fills in everything the request left out", async () => {
const approved = await prepareApprovalTx(
providerWith(),
signer.address,
TX_PARAMS,
);
expect(approved).toEqual({
type: 2,
from: signer.address,
chainId: "0x1",
nonce: "0x7",
gasLimit: "0x5208",
maxPriorityFeePerGas: "0x3b9aca00",
maxFeePerGas: "0x77359400",
to: RECIPIENT,
value: TX_PARAMS.value,
data: TX_PARAMS.data,
accessList: [],
});
});
// The object is displayed, signed and verified against on the far side of
// chrome.runtime.sendMessage, which is JSON: a bigint would throw on the
// way out and a field that did not survive the trip would be a field the
// user was shown and nothing compared.
test("survives the messaging boundary unchanged", async () => {
const approved = await prepareApprovalTx(
providerWith(),
signer.address,
TX_PARAMS,
);
expect(JSON.parse(JSON.stringify(approved))).toEqual(approved);
for (const value of Object.values(approved)) {
expect(typeof value).not.toBe("bigint");
}
});
test("carries exactly the fields its type serializes, and the signer", async () => {
const approved = await prepareApprovalTx(
providerWith(),
signer.address,
TX_PARAMS,
);
expect(Object.keys(approved).sort()).toEqual(
["type", "from", ...SERIALIZED_FIELDS[2]].sort(),
);
});
test("produces a legacy transaction when that is all the node quotes", async () => {
const approved = await prepareApprovalTx(
legacyProvider,
signer.address,
TX_PARAMS,
);
expect(approved.type).toBe(0);
expect(approved.gasPrice).toBe("0x77359400");
expect(approved.maxFeePerGas).toBeUndefined();
expect(Object.keys(approved).sort()).toEqual(
["type", "from", ...SERIALIZED_FIELDS[0]].sort(),
);
});
test("keeps a nonce, gas limit and fee the request did fix", async () => {
const approved = await prepareApprovalTx(
providerWith(),
signer.address,
{
...TX_PARAMS,
nonce: "0x2",
gasLimit: "0x30d40",
maxFeePerGas: "0x12a05f200",
maxPriorityFeePerGas: "0x3b9aca00",
},
);
expect(approved.nonce).toBe("0x2");
expect(approved.gasLimit).toBe("0x30d40");
expect(approved.maxFeePerGas).toBe("0x12a05f200");
});
test("carries an access list the request asked for", async () => {
const approved = await prepareApprovalTx(
providerWith(),
signer.address,
{
...TX_PARAMS,
accessList: [{ address: RECIPIENT, storageKeys: [] }],
},
);
expect(approved.accessList).toEqual([
{ address: RECIPIENT, storageKeys: [] },
]);
});
// The request is page-controlled. Anything this wallet does not act on is
// dropped before ethers sees it, so a field a future ethers learns to
// carry cannot be picked up out of it without this module knowing.
test("drops request fields this wallet does not act on", async () => {
const approved = await prepareApprovalTx(
providerWith(),
signer.address,
{
...TX_PARAMS,
authorizationList: [{ address: RECIPIENT }],
blobVersionedHashes: ["0x01" + "ab".repeat(31)],
customData: { anything: true },
},
);
expect(approved.authorizationList).toBeUndefined();
expect(approved.blobVersionedHashes).toBeUndefined();
expect(approved.customData).toBeUndefined();
expect(approved.type).toBe(2);
});
test("refuses a transaction type this wallet does not sign", async () => {
await expect(
prepareApprovalTx(providerWith(), signer.address, {
...TX_PARAMS,
type: 4,
}),
).rejects.toThrow(/type this wallet does not sign/);
});
test("refuses to raise an approval with no active address", async () => {
await expect(
prepareApprovalTx(providerWith(), null, TX_PARAMS),
).rejects.toThrow(/no active address/);
});
// The ceilings as a backstop: equality with the screen cannot bound what
// the node talks the wallet into putting on the screen, so it is refused
// before the user is shown anything.
test("refuses a fee the node quoted above the ceiling", async () => {
const gouging = providerWith({
getFeeData: async () => ({
gasPrice: MAX_FEE_PER_GAS + 1n,
maxFeePerGas: MAX_FEE_PER_GAS + 1n,
maxPriorityFeePerGas: 1000000000n,
}),
});
await expect(
prepareApprovalTx(gouging, signer.address, TX_PARAMS),
).rejects.toThrow(/fee per gas far above any plausible value/);
});
test("refuses a gas limit the node estimated above the ceiling", async () => {
const absurd = providerWith({
estimateGas: async () => MAX_GAS_LIMIT + 1n,
});
await expect(
prepareApprovalTx(absurd, signer.address, TX_PARAMS),
).rejects.toThrow(/gas limit no network this wallet supports/);
});
// No approval and no window: the failure goes back to the page the click
// came from, in a sentence.
test("reports a failed estimate as a full sentence", async () => {
const reverting = providerWith({
estimateGas: async () => {
throw new Error("execution reverted: ERC20: transfer amount");
},
});
let thrown;
try {
await prepareApprovalTx(reverting, signer.address, TX_PARAMS);
} catch (e) {
thrown = e;
}
expect(thrown.message).toMatch(
/^The transaction could not be prepared/,
);
expect(thrown.message).toMatch(/execution reverted/);
expect(thrown.message).toMatch(/^[A-Z].*\.$/);
});
// Without a bound, an unreachable node leaves the page's promise pending
// with nothing on screen to explain it.
test("gives up on a node that never answers", async () => {
jest.useFakeTimers();
try {
const hanging = providerWith({
estimateGas: () => new Promise(() => {}),
});
const pending = prepareApprovalTx(
hanging,
signer.address,
TX_PARAMS,
);
const settled = expect(pending).rejects.toThrow(
/did not answer in time/,
);
await jest.advanceTimersByTimeAsync(POPULATE_TIMEOUT_MS + 1);
await settled;
} finally {
jest.useRealTimers();
}
});
});
describe("serializeApprovedTx", () => {
// Unreachable through prepareApprovalTx while the request type is checked
// first, which is what it is for: a node or an ethers upgrade that
// populates a type this wallet does not sign must not produce an approval.
test("refuses a populated transaction of a type this wallet does not sign", () => {
expect(() =>
serializeApprovedTx(
{ type: 3, to: RECIPIENT, nonce: 7 },
signer.address,
),
).toThrow(/type this wallet does not sign/);
});
test("refuses a populated transaction missing a quantity", () => {
expect(() =>
serializeApprovedTx(
{
type: 2,
chainId: 1n,
nonce: 7,
gasLimit: 21000n,
maxFeePerGas: 2000000000n,
to: RECIPIENT,
value: 0n,
data: "0x",
},
signer.address,
),
).toThrow(/did not supply a maxPriorityFeePerGas/);
});
test("keeps a contract creation's absent recipient absent", () => {
const approved = serializeApprovedTx(
{
type: 0,
chainId: 1n,
nonce: 7,
gasPrice: 2000000000n,
gasLimit: 21000n,
to: null,
value: 0n,
data: "0x600160005500",
},
signer.address,
);
expect(approved.to).toBeNull();
expect(approved.value).toBe("0x0");
expect(approved.data).toBe("0x600160005500");
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,158 +0,0 @@
// Tests for the copy on the address-removal confirmation (issue #162).
//
// The screen's whole job is to warn before a destructive-looking action, so
// the copy is the substance and is tested as such. Two things it must not
// get wrong: what it takes to get the address back — the app refuses both
// obvious routes — and what counts as holding something, which is any
// ERC-20 as well as ETH, at any size, including a balance that rounds to
// zero at the four decimals the balance lines render. The DOM behaviour
// around them is driven against the real popup by tests/e2e/run.js.
// helpers.js pulls in state.js, which reads chrome.storage.local at load.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { addressHoldsFunds } = require("../src/popup/views/helpers");
const {
recoveryPathText,
balanceWarningHtml,
} = require("../src/popup/views/deleteAddress");
const { prices, clearPrices } = require("../src/shared/prices");
const USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
const EMPTY = { address: "0x1", balance: "0.0000", tokenBalances: [] };
const ETH_ONLY = { address: "0x1", balance: "1.5", tokenBalances: [] };
const DUST = { address: "0x1", balance: "0.00001", tokenBalances: [] };
const TOKEN_ONLY = {
address: "0x1",
balance: "0.0000",
tokenBalances: [{ address: USDC, symbol: "USDC", balance: "2500.0" }],
};
const ZERO_TOKEN = {
address: "0x1",
balance: "0",
tokenBalances: [{ address: USDC, symbol: "USDC", balance: "0" }],
};
afterEach(() => {
clearPrices();
});
describe("what the screen says it takes to get the address back", () => {
// The screen used to promise the address "can be brought back at any
// time by importing this wallet's recovery phrase again". That import is
// refused as a duplicate for as long as the wallet is present, which it
// always is here — a wallet never gives up its last address.
test("it does not promise a re-import while the wallet is here", () => {
const text = recoveryPathText({ type: "hd" });
expect(text).not.toMatch(/at any time/);
expect(text).toContain("is refused while this wallet is still here");
});
test("it names deleting the whole wallet as the route back", () => {
expect(recoveryPathText({ type: "hd" })).toContain(
"delete the whole wallet in Settings",
);
});
// The scan after a re-import finds used addresses only, so an address
// that never saw a transaction does not come back at all. Saying so is
// the difference between a warning and a false reassurance.
test("it states the limit: only on-chain activity is found", () => {
const text = recoveryPathText({ type: "hd" });
expect(text).toContain("only finds addresses that have on-chain");
expect(text).toContain("never been used is not found by it");
});
// The screen is offered on xprv wallets too, and an xprv wallet holds no
// recovery phrase — telling its owner to import one would send them
// looking for words that do not exist.
test("an xprv wallet is told about its extended private key", () => {
const text = recoveryPathText({ type: "xprv" });
expect(text).toContain("extended private key");
expect(text).not.toContain("recovery phrase");
});
test("an HD wallet is told about its recovery phrase", () => {
const text = recoveryPathText({ type: "hd" });
expect(text).toContain("recovery phrase");
expect(text).not.toContain("extended private key");
});
});
describe("whether an address holds anything", () => {
test("ETH counts", () => {
expect(addressHoldsFunds(ETH_ONLY)).toBe(true);
});
// The case that decides the screen: no ETH at all, and $2500 of a
// stablecoin sitting at the address.
test("an ERC-20 balance counts even with no ETH", () => {
expect(addressHoldsFunds(TOKEN_ONLY)).toBe(true);
});
// 0.00001 ETH renders as "0.0000" at four decimals. It is still money.
test("an ETH balance below the displayed precision counts", () => {
expect(addressHoldsFunds(DUST)).toBe(true);
});
test("an address holding nothing does not", () => {
expect(addressHoldsFunds(EMPTY)).toBe(false);
expect(addressHoldsFunds(ZERO_TOKEN)).toBe(false);
});
test("a missing address or missing fields do not", () => {
expect(addressHoldsFunds(undefined)).toBe(false);
expect(addressHoldsFunds({ address: "0x1" })).toBe(false);
});
});
describe("the balance warning on the removal confirmation", () => {
test("an address holding nothing gets a blank line, not a warning", () => {
expect(balanceWarningHtml(EMPTY)).toBe("&nbsp;");
expect(balanceWarningHtml(ZERO_TOKEN)).toBe("&nbsp;");
});
test("an ERC-20-only address is warned about, and its token listed", () => {
const html = balanceWarningHtml(TOKEN_ONLY);
expect(html).toContain("This address holds a balance.");
expect(html).toContain("does not move or spend anything");
expect(html).toContain("USDC");
expect(html).toContain("2500.0000");
});
// The rendered line says 0.0000 for this address — that is the display
// format, shared with Home and AddressDetail — and the warning is shown
// all the same, because the balance is not zero.
test("an ETH balance that renders as 0.0000 is warned about", () => {
const html = balanceWarningHtml(DUST);
expect(html).toContain("This address holds a balance.");
expect(html).toContain("<span>0.0000</span>");
});
// The sentence must not assert an amount, because any amount it could
// assert has been rounded: "This address holds 0.0000 ETH." is what the
// rounded form produces for an address that holds real money.
test("the warning sentence asserts no rounded amount", () => {
for (const addr of [DUST, ETH_ONLY, TOKEN_ONLY]) {
expect(balanceWarningHtml(addr)).not.toMatch(
/holds [\d.]+ (ETH|USDC)/,
);
}
});
test("the USD total is shown when prices are known", () => {
prices.ETH = 2000;
prices.USDC = 1;
expect(balanceWarningHtml(TOKEN_ONLY)).toContain("Total: $2,500.00");
expect(balanceWarningHtml(ETH_ONLY)).toContain("Total: $3,000.00");
});
// getAddressValueUsd() returns null on testnet and before the first
// price fetch. A "Total: $0.00" there would be a lie about the holdings.
test("no USD total is shown when prices are not known", () => {
expect(balanceWarningHtml(TOKEN_ONLY)).not.toContain("Total:");
});
});

View File

@@ -1,243 +0,0 @@
// Tests for the dust threshold field in Settings (issue #233).
//
// Two halves: what the parse accepts, and what the settings view does with a
// rejection. The view half runs against the real change handler with the DOM
// helpers stubbed out, because the bug was not in the parse — it was that a
// rejection said nothing.
const {
DUST_THRESHOLD_MESSAGE,
parseDustThresholdGwei,
} = require("../src/popup/dustThreshold");
describe("parsing the dust threshold", () => {
test("accepts a whole number of gwei", () => {
expect(parseDustThresholdGwei("100000")).toBe(100000);
expect(parseDustThresholdGwei("1")).toBe(1);
});
// Zero is a real setting, not an empty field: it hides nothing.
test("accepts zero", () => {
expect(parseDustThresholdGwei("0")).toBe(0);
});
test("accepts surrounding whitespace", () => {
expect(parseDustThresholdGwei(" 250 ")).toBe(250);
});
test("rejects an empty field", () => {
expect(parseDustThresholdGwei("")).toBe(null);
expect(parseDustThresholdGwei(" ")).toBe(null);
});
test("rejects a negative threshold", () => {
expect(parseDustThresholdGwei("-1")).toBe(null);
});
// parseInt used to read this as 1, which is not what was typed.
test("rejects a fractional value", () => {
expect(parseDustThresholdGwei("1.5")).toBe(null);
expect(parseDustThresholdGwei("1.0")).toBe(null);
});
// parseInt used to read this as 100. The unit is printed beside the
// field already.
test("rejects a value carrying its unit", () => {
expect(parseDustThresholdGwei("100 gwei")).toBe(null);
});
// Number() reads this as 16. Storing 16 for a field that was told to
// want a whole number of gwei would be the same silent substitution the
// message exists to end.
test("rejects hex notation", () => {
expect(parseDustThresholdGwei("0x10")).toBe(null);
});
// Number() reads this as 1000.
test("rejects exponent notation", () => {
expect(parseDustThresholdGwei("1e3")).toBe(null);
});
test("rejects other non-numeric input", () => {
expect(parseDustThresholdGwei("lots")).toBe(null);
expect(parseDustThresholdGwei("+5")).toBe(null);
expect(parseDustThresholdGwei("Infinity")).toBe(null);
expect(parseDustThresholdGwei(undefined)).toBe(null);
expect(parseDustThresholdGwei(5)).toBe(null);
});
// Beyond 2^53 the digits would round on the way in, so the stored
// threshold would not be the one typed.
test("rejects a value too large to hold exactly", () => {
expect(parseDustThresholdGwei("9007199254740993")).toBe(null);
});
});
describe("the rejection message", () => {
// README, Language & Labeling: error messages are full sentences.
test("is a full sentence naming the constraint", () => {
expect(DUST_THRESHOLD_MESSAGE).toMatch(/^[A-Z].*\.$/);
expect(DUST_THRESHOLD_MESSAGE).toContain("whole number of gwei");
expect(DUST_THRESHOLD_MESSAGE).toContain("zero or greater");
});
});
describe("the flash line the message is shown in", () => {
const fs = require("fs");
const path = require("path");
const POPUP_HTML = fs.readFileSync(
path.join(__dirname, "..", "src", "popup", "index.html"),
"utf8",
);
// This asserts only that the reservation exists in the markup. It does
// NOT and CANNOT assert that the message fits inside it: jest runs on
// the node environment here, with no layout engine, so every rendered
// height is zero. An earlier version of this block claimed to pin the
// No Layout Shift policy with this regex, and it passed at any message
// length, including one that wrapped to two lines and pushed the
// settings view down 12px.
//
// The assertion that actually measures — empty line vs. the message,
// real Chromium, documented 360x600 popup — is
// "a rejected dust threshold shifts no layout (#233)" in
// tests/e2e/run.js, run by make test-e2e. It is not in make check
// because REPO_POLICIES.md caps make test at 20 seconds and a browser
// suite does not fit; run it before changing the wording.
test("reserves its height in the markup", () => {
const flashLine = POPUP_HTML.match(
/<div\s+id="flash-msg"\s+class="([^"]*)"/,
);
expect(flashLine).not.toBeNull();
expect(flashLine[1]).toMatch(/min-h-\[/);
});
});
describe("the settings view on a change to the field", () => {
let elements;
let flashes;
let saves;
let state;
// A stand-in for one DOM node: enough of an element for init() to set
// properties on it and hang listeners off it.
function fakeElement() {
return {
value: "",
checked: false,
textContent: "",
href: "",
style: {},
dataset: {},
classList: { add() {}, remove() {} },
listeners: {},
addEventListener(event, handler) {
this.listeners[event] = handler;
},
querySelectorAll: () => [],
};
}
function loadSettingsView() {
elements = {};
flashes = [];
saves = 0;
jest.resetModules();
jest.doMock("../src/popup/views/helpers", () => ({
$: (id) => (elements[id] ||= fakeElement()),
showView: () => {},
updateDebugBanner: () => {},
showFlash: (msg) => flashes.push(msg),
escapeHtml: (s) => s,
flashCopyFeedback: () => {},
goBack: () => {},
pushCurrentView: () => {},
onViewLeave: () => {},
VIEWS: [],
}));
state = require("../src/shared/state").state;
state.dustThresholdGwei = 100000;
const settings = require("../src/popup/views/settings");
settings.init({});
return elements["settings-dust-threshold"];
}
beforeEach(() => {
globalThis.chrome = {
runtime: { sendMessage: () => {} },
storage: {
local: {
get: async () => ({}),
set: async () => {
saves++;
},
},
},
};
});
afterEach(() => {
jest.dontMock("../src/popup/views/helpers");
delete globalThis.chrome;
});
async function change(field, typed) {
field.value = typed;
await field.listeners.change();
}
test("a valid value is stored and says nothing", async () => {
const field = loadSettingsView();
await change(field, "250");
expect(state.dustThresholdGwei).toBe(250);
expect(field.value).toBe(250);
expect(flashes).toEqual([]);
expect(saves).toBe(1);
});
test("a rejected value shows the message and is not stored", async () => {
const field = loadSettingsView();
await change(field, "1.5");
expect(state.dustThresholdGwei).toBe(100000);
expect(flashes).toEqual([DUST_THRESHOLD_MESSAGE]);
expect(saves).toBe(0);
});
// The snap-back is the behaviour the message explains, so it stays.
test("a rejected value still resyncs the field to what is stored", async () => {
const field = loadSettingsView();
await change(field, "100 gwei");
expect(field.value).toBe(100000);
});
test("every rejected notation gets the same one message", async () => {
for (const typed of ["", "-1", "1.5", "100 gwei", "0x10", "1e3"]) {
const field = loadSettingsView();
await change(field, typed);
expect(flashes).toEqual([DUST_THRESHOLD_MESSAGE]);
expect(state.dustThresholdGwei).toBe(100000);
}
});
test("zero is accepted, not treated as an empty field", async () => {
const field = loadSettingsView();
await change(field, "0");
expect(state.dustThresholdGwei).toBe(0);
expect(flashes).toEqual([]);
});
});

View File

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

View File

@@ -110,26 +110,6 @@ class Driver {
"extensions.webextensions.uuids": JSON.stringify({ "extensions.webextensions.uuids": JSON.stringify({
[EXTENSION_ID]: EXTENSION_UUID, [EXTENSION_ID]: EXTENSION_UUID,
}), }),
// The container has loopback and nothing else. Firefox's own
// link-status detection can read that as "offline" and then
// refuse every request, including the ones to the loopback dApp
// origin the suite serves; this takes the decision away from it.
"network.manage-offline-status": false,
// Force the site-connection prompt down its windows.create()
// fallback.
//
// src/background/index.js prefers the toolbar-anchored popup for
// that one approval and opens a real window only when
// openPopup() refuses. A panel is not a top-level browsing
// context, so WebDriver cannot see it, list it or click in it —
// the same blind spot the Chrome harness documents. Leaving this
// at its default would make which path runs depend on whether a
// headless Firefox counts as having had a user gesture, which is
// not a thing to leave to chance in a suite that has to be able
// to fail. The window path is shipped code and the same approval
// id, so what is driven is real; what is NOT covered either way
// is the panel presentation itself.
"extensions.openPopupWithoutUserGesture.enabled": false,
}; };
const value = await this.send("POST", "/session", { const value = await this.send("POST", "/session", {
@@ -199,16 +179,6 @@ class Driver {
return this.session("POST", "/execute/sync", { script, args }); return this.session("POST", "/execute/sync", { script, args });
} }
// The asynchronous form: the script is handed a resolve callback as its
// last argument and the call settles when that is invoked. Everything
// interesting about an extension page is promise-shaped — storage reads,
// the provider's own request() — and /execute/sync cannot wait for any
// of it.
async executeAsync(script, args = []) {
await this.setContext("content");
return this.session("POST", "/execute/async", { script, args });
}
// Runs in the privileged chrome scope, where Services and Ci exist. // Runs in the privileged chrome scope, where Services and Ci exist.
async executeChrome(script, args = []) { async executeChrome(script, args = []) {
await this.setContext("chrome"); await this.setContext("chrome");
@@ -359,63 +329,6 @@ class Driver {
[selector], [selector],
); );
} }
// ------------------------------------------------------------ windows
//
// The approval prompts this suite drives are separate top-level windows
// the extension opens itself, so every one of them is a window handle
// here and the suite has to move between them explicitly.
async windowHandles() {
return this.session("GET", "/window/handles");
}
async currentWindow() {
return this.session("GET", "/window");
}
async switchToWindow(handle) {
await this.setContext("content");
await this.session("POST", "/window", { handle });
}
async newWindow(type = "window") {
await this.setContext("content");
const value = await this.session("POST", "/window/new", { type });
return value.handle;
}
// Closes the current window and leaves the session on `fallback`, because
// a session whose current window is gone fails every subsequent command
// with "no such window" rather than with anything diagnosable.
async closeWindow(fallback) {
await this.setContext("content");
await this.session("DELETE", "/window");
if (fallback) await this.switchToWindow(fallback);
}
async url() {
return this.session("GET", "/url");
}
// The handle of the first window whose URL matches, or null. Restores the
// window that was current before the search either way: a probe that
// silently relocates the session is a trap for the step after it.
async findWindow(predicate) {
const origin = await this.currentWindow();
try {
for (const handle of await this.windowHandles()) {
await this.switchToWindow(handle);
if (predicate(await this.url())) return handle;
}
return null;
} finally {
// Tolerated: the window the search started from may have been the
// one that just closed, and a throw in here would replace the
// real result with "no such window".
await this.switchToWindow(origin).catch(() => {});
}
}
} }
// ------------------------------------------------------- error capture // ------------------------------------------------------- error capture
@@ -436,26 +349,14 @@ class Driver {
// background page, which BiDi would not have covered even if it worked. // background page, which BiDi would not have covered even if it worked.
// Background-page capture is verified by probe — a throw at the top of // Background-page capture is verified by probe — a throw at the top of
// src/background/index.js, which kills the background page outright, fails // src/background/index.js, which kills the background page outright, fails
// the run. // the run. Content-script errors should arrive by the same route, but that
// // is UNVERIFIED here and must not be claimed: the container runs with
// Content scripts ARE now exercised: tests/e2e/firefox/dapp.js serves a page // --network none, so there is no http:// page for a content script to be
// from loopback, which survives --network none, and the suite drives the // injected into and this suite never exercises one.
// EIP-1193 round trips through the content script injected into it. What is
// still unproven is the CAPTURE, not the execution — no probe has forced a
// throw from inside a content script and watched it fail the run, so an
// uncaught content-script error arriving by this route remains an
// expectation rather than a demonstrated fact. Do not claim otherwise.
// //
// Warnings are excluded so the semantics match Playwright's pageerror: // Warnings are excluded so the semantics match Playwright's pageerror:
// uncaught errors only. // uncaught errors only.
// const READ_ERRORS_SCRIPT = `
// The read and the clear are ONE chrome script on purpose. Splitting them
// into two round trips leaves a blind window between them in which an
// error is logged into a buffer that is about to be discarded, and is
// destroyed unread rather than deferred to the next drain. That was not
// theoretical: with a separate reset() call, a probe of 100 sequenced
// throws at 20ms spacing lost one of them outright.
const DRAIN_ERRORS_SCRIPT = `
const origin = arguments[0]; const origin = arguments[0];
const out = []; const out = [];
for (const raw of Services.console.getMessageArray() || []) { for (const raw of Services.console.getMessageArray() || []) {
@@ -475,7 +376,6 @@ const DRAIN_ERRORS_SCRIPT = `
cat: e.category, cat: e.category,
}); });
} }
Services.console.reset();
return out; return out;
`; `;
@@ -485,20 +385,25 @@ class ConsoleErrors {
this.originPrefix = originPrefix; this.originPrefix = originPrefix;
} }
// Everything logged since the last take, read and cleared atomically // Everything logged since the last reset, then clear. Poll-based, so
// in a single chrome round trip. Poll-based, so an error is attributed // an error is attributed to the step that was running when it was
// to the step that was running when it was drained, not to the moment // drained, not to the moment inside that step at which it happened —
// inside that step at which it happened — see the limitation note in // see the limitation note in run.js.
// run.js. An error that arrives mid-drain is not lost — it makes this
// batch or the next one — but the console service ring buffer holds
// only 250 messages, so more than that between two takes evicts the
// oldest unread. A clean run peaks at 4.
async take() { async take() {
const found = await this.driver.executeChrome(DRAIN_ERRORS_SCRIPT, [ const found = await this.driver.executeChrome(READ_ERRORS_SCRIPT, [
this.originPrefix, this.originPrefix,
]); ]);
await this.reset();
return found || []; return found || [];
} }
// Discards the console outright. Anything logged and not yet taken is
// destroyed unread, so this is only ever correct straight after a
// take(); to move past a window of errors, take() them and report
// them somewhere.
async reset() {
await this.driver.executeChrome("Services.console.reset(); return 0;");
}
} }
// ------------------------------------------------------------- startup // ------------------------------------------------------------- startup

View File

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

View File

@@ -53,47 +53,15 @@ function isAllowed(text) {
// after that — the route handler and the console listeners are gone with // after that — the route handler and the console listeners are gone with
// the context — so there is no post-teardown phase to collect, and this // the context — so there is no post-teardown phase to collect, and this
// class deliberately offers no mechanism pretending to cover one. // class deliberately offers no mechanism pretending to cover one.
//
// One narrow exception exists, and it is not a mute: expect(). A test that
// drives a failure path on purpose — a refused gas estimate, say — provokes
// the console.error the code is supposed to emit, and that error is the
// behaviour under test rather than an escape. Declaring it consumes exactly
// one matching record and no more, and an expectation nothing matched fails
// its test just as an unexpected error does. So it cannot be used to
// silence anything: it can only assert that a specific error happened.
class ErrorCollector { class ErrorCollector {
constructor() { constructor() {
this.entries = []; this.entries = [];
this.taken = 0; this.taken = 0;
this.expectations = [];
}
// Declare a console.error this test is about to cause deliberately.
// `label` names it in the failure message if it never arrives.
expect(label, pattern) {
this.expectations.push({ label, pattern, matched: false });
}
// Declared expectations that nothing matched, clearing the list so each
// test starts with none outstanding.
unmatchedExpectations() {
const out = this.expectations
.filter((e) => !e.matched)
.map((e) => e.label);
this.expectations = [];
return out;
} }
record(kind, text) { record(kind, text) {
const line = kind + ": " + String(text).split("\n")[0]; const line = kind + ": " + String(text).split("\n")[0];
if (isAllowed(line)) return; if (isAllowed(line)) return;
const expected = this.expectations.find(
(e) => !e.matched && e.pattern.test(line),
);
if (expected) {
expected.matched = true;
return;
}
this.entries.push(line); this.entries.push(line);
} }
@@ -322,18 +290,13 @@ async function createWallet(page) {
return phrase; return phrase;
} }
// Reach the address detail screen of the FIRST address of the first wallet, // Reach the address detail screen from wherever the popup restored to.
// from wherever the popup restored to. Clicking .address-row does not open // Clicking .address-row does not open it; the [info] button does.
// it; the [info] button does.
//
// .first() rather than a bare selector because the suite adds a second
// wallet partway through, and every later test would otherwise die in
// Playwright's strict mode rather than on an assertion.
async function openAddressDetail(page) { async function openAddressDetail(page) {
const onAddress = await page.isVisible("#view-address"); const onAddress = await page.isVisible("#view-address");
if (!onAddress) { if (!onAddress) {
await visible(page, "#view-main"); await visible(page, "#view-main");
await page.locator("#wallet-list .btn-addr-info").first().click(); await page.click("#wallet-list .btn-addr-info");
} }
await visible(page, "#view-address"); await visible(page, "#view-address");
} }

View File

@@ -23,8 +23,6 @@
"use strict"; "use strict";
const { Transaction } = require("ethers");
// Fictional ERC-20 used to seed the transaction-detail test. The symbol // Fictional ERC-20 used to seed the transaction-detail test. The symbol
// must not collide with any entry in src/shared/tokenList.js, or // must not collide with any entry in src/shared/tokenList.js, or
// isSpoofedSymbol() in src/shared/transactions.js drops the transfer as a // isSpoofedSymbol() in src/shared/transactions.js drops the transfer as a
@@ -55,189 +53,18 @@ const STUB_TX_TIMESTAMP = "2026-01-02T03:04:05.000000Z";
// log.errorf(), i.e. console.error, which fails the run on its own. // log.errorf(), i.e. console.error, which fails the run on its own.
const ZERO_WORD = "0x" + "0".repeat(64); const ZERO_WORD = "0x" + "0".repeat(64);
function hex(value) {
return "0x" + BigInt(value).toString(16);
}
// A bigint as a 32-byte ABI word.
function word(value) {
return "0x" + BigInt(value).toString(16).padStart(64, "0");
}
// -------------------------------------------------------- dApp fixture
//
// The origin the EIP-1193 test page is served from, and the page itself.
//
// It is a fixture like every other one in this file: the route handler
// fulfils the navigation from the string below, so the page never comes
// from a remote origin and nothing about the dApp round trips leaves the
// container. `.test` is reserved by RFC 6761 and has no owner to reach in
// the first place; the launch arguments map every host to NOTFOUND anyway.
//
// What the page deliberately does NOT do is load a provider. window.ethereum
// is put there by the shipped manifest's MAIN-world content script, exactly
// as it is on any http(s) page a user visits, so what these tests speak to
// is the real inpage provider and not a copy the harness wired up.
//
// DAPP_HTML below is exported and served verbatim by the Firefox suite too
// (tests/e2e/firefox/dapp.js), from a loopback origin rather than through a
// route handler. The two suites drive different browsers over different
// protocols, but the page they drive — the __dapp API, the message log — is
// one fixture, so an assertion written against it means the same thing on
// both.
const DAPP_ORIGIN = "https://dapp.e2e.test";
const DAPP_URL = DAPP_ORIGIN + "/";
// Requests are parked rather than awaited. An approval prompt only exists
// while its call is in flight, so a test that awaited the promise could
// never drive the popup that has to settle it; start() files the promise
// under a key and settle() collects it once the prompt has been dealt with.
//
// The rejection branch records the whole observable shape of the error as it
// arrives — name, message, and whether a `code` is present at all as distinct
// from its value. EIP-1193 says a user rejection is a ProviderRpcError
// carrying code 4001; what the page can actually see is recorded here rather
// than assumed, and asserted in run.js.
//
// The message log is the page's half of the boundary observation: every
// AUTISTMASK_* message that crosses between this page and the content
// script, in both directions, verbatim.
const DAPP_HTML = [
"<!doctype html>",
'<html lang="en">',
"<head>",
'<meta charset="utf-8">',
"<title>AutistMask e2e dApp</title>",
// Inline and empty: without it Chromium asks for /favicon.ico, which
// the unstubbed-request guard would report as escaping traffic.
'<link rel="icon" href="data:,">',
"</head>",
"<body>",
"<h1>AutistMask e2e dApp</h1>",
"<script>",
"window.__dapp = {",
" messages: [],",
" calls: {},",
" start: function (key, method, params) {",
" window.__dapp.calls[key] = window.ethereum",
" .request({ method: method, params: params })",
" .then(",
" function (result) {",
" return { settled: 'resolved', result: result };",
" },",
" function (error) {",
" return {",
" settled: 'rejected',",
" message: String((error && error.message) || error),",
" name: error ? error.name : undefined,",
" hasCode: !!error && 'code' in Object(error),",
" code: error ? error.code : undefined,",
" };",
" },",
" );",
" },",
" settle: function (key) {",
" return window.__dapp.calls[key];",
" },",
"};",
"window.addEventListener('message', function (event) {",
" if (event.source !== window) return;",
" var d = event.data;",
" if (!d || typeof d.type !== 'string') return;",
" if (d.type.indexOf('AUTISTMASK') !== 0) return;",
" window.__dapp.messages.push(d);",
"});",
"</script>",
"</body>",
"</html>",
].join("\n");
// ------------------------------------------------------------ fee fixture
//
// The confirmation screen carries two different numbers for the same
// transaction and may gate on only one of them:
//
// reserve = gasLimit * maxFeePerGas — what a node requires to be
// available for a type-2 transaction, and what the spend gate
// must use.
// estimate = gasLimit * gasPrice — what the transfer is expected to
// actually cost. Display only.
//
// Issue #154 was the gate reading the smaller of the two. ethers derives
// maxFeePerGas as baseFeePerGas * 2 + maxPriorityFeePerGas, so the numbers
// below put the reserve at very nearly twice the estimate. That gap is the
// entire point of these values: it leaves room for a send that an
// estimate-based gate accepts and a reserve-based gate refuses, which is
// what lets the ConfirmTx tests tell the two apart at all. Collapse the gap
// — by dropping baseFeePerGas from the block below, say — and those tests
// go on passing while asserting nothing.
const GAS_LIMIT = 21000n;
const BASE_FEE_WEI = 100000000000n; // 100 gwei
const PRIORITY_FEE_WEI = 1000000000n; // 1 gwei
const GAS_PRICE_WEI = BASE_FEE_WEI + PRIORITY_FEE_WEI; // 101 gwei
const MAX_FEE_WEI = BASE_FEE_WEI * 2n + PRIORITY_FEE_WEI; // 201 gwei
const FEE_ESTIMATE_WEI = GAS_LIMIT * GAS_PRICE_WEI; // 0.002121 ETH
const FEE_RESERVE_WEI = GAS_LIMIT * MAX_FEE_WEI; // 0.004221 ETH
const RPC_RESULTS = { const RPC_RESULTS = {
eth_chainId: "0x1", eth_chainId: "0x1",
net_version: "1", net_version: "1",
eth_blockNumber: "0x1406f40", eth_blockNumber: "0x1406f40",
eth_getBalance: "0x0", eth_getBalance: "0x0",
eth_call: ZERO_WORD, eth_call: ZERO_WORD,
eth_getCode: "0x", eth_gasPrice: "0x3b9aca00",
eth_gasPrice: hex(GAS_PRICE_WEI), eth_estimateGas: "0x5208",
eth_estimateGas: hex(GAS_LIMIT),
eth_getTransactionCount: "0x0", eth_getTransactionCount: "0x0",
eth_maxPriorityFeePerGas: hex(PRIORITY_FEE_WEI), eth_maxPriorityFeePerGas: "0x3b9aca00",
// "not mined yet", which is what a node answers for a transaction it has
// only just accepted. The wait screen the dApp transaction approval hands
// off to polls this every 10 seconds; leaving it unstubbed would report
// the poll as escaping traffic the moment a test outlived one tick.
eth_getTransactionReceipt: null,
}; };
// The "latest" block, which ethers' getFeeData() reads baseFeePerGas from
// to derive maxFeePerGas. Without it every fee is a legacy gasPrice, the
// reserve and the estimate collapse to the same number, and the gate tests
// stop being able to distinguish them.
function latestBlock() {
return {
hash: "0x" + "11".repeat(32),
parentHash: "0x" + "22".repeat(32),
number: hex(STUB_BLOCK_NUMBER),
timestamp: hex(1767326645),
nonce: "0x0000000000000000",
difficulty: "0x0",
gasLimit: "0x1c9c380",
gasUsed: "0xf4240",
miner: STUB_COUNTERPARTY,
extraData: "0x",
baseFeePerGas: hex(BASE_FEE_WEI),
transactions: [],
};
}
// keccak("decimals()")[0:4].
const SELECTOR_DECIMALS = "0x313ce567";
// Every eth_call still answers with a zero word except decimals() on the
// stub token. ethers reads that before it can encode an ERC-20 transfer,
// and a zero there makes parseUnits() reject any fractional amount — so the
// ERC-20 confirmation path would fail its gas estimate for a reason that
// has nothing to do with what is being tested.
function ethCallResult(req) {
const call = Array.isArray(req.params) ? req.params[0] : null;
if (!call || typeof call !== "object") return ZERO_WORD;
const data = String(call.data || call.input || "").toLowerCase();
const to = String(call.to || "").toLowerCase();
if (data.startsWith(SELECTOR_DECIMALS) && to === STUB_TOKEN.address) {
return word(STUB_TOKEN.decimals);
}
return ZERO_WORD;
}
function tokenObject() { function tokenObject() {
return { return {
address_hash: STUB_TOKEN.address, address_hash: STUB_TOKEN.address,
@@ -265,18 +92,6 @@ function tokenTransferItems(address) {
]; ];
} }
// A holding of 1.5 E2E, in the shape src/shared/balances.js parses. Serving
// this is what puts an ERC-20 in the send screen's token dropdown, which is
// the only way the confirmation screen's ERC-20 path can be reached.
function tokenBalanceItems() {
return [
{
value: "1500000",
token: tokenObject(),
},
];
}
// Full details for STUB_TX_HASH. raw_input is "0x" so the calldata // Full details for STUB_TX_HASH. raw_input is "0x" so the calldata
// decoder short-circuits; the on-chain detail fields still populate. // decoder short-circuits; the on-chain detail fields still populate.
function transactionDetails() { function transactionDetails() {
@@ -306,107 +121,7 @@ function blockscoutAddress(pathname) {
return m ? m[1] : null; return m ? m[1] : null;
} }
function sleep(ms) { function handleRpc(route, postData, report) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// How long a deliberately held reply is allowed to stay held, and how often
// the release flag is re-read while it is.
const HOLD_POLL_MS = 25;
const HOLD_MAX_MS = 30000;
// Hold a gas estimate open for as long as the test asks.
//
// opts.holdGasEstimate is read here rather than captured, so a test flips it
// on the same options object the route was registered with — the same
// pattern as seedTokenTransfer. This is the only way to observe the
// confirmation screen while its estimate is genuinely in flight; sampling
// the screen and hoping to win a race against the network would assert
// nothing on a slow machine.
//
// It never gives up quietly. A hold that outlives the bound is reported like
// any other harness fault, because a "pending" state that stopped being
// pending on its own is a green assertion about the wrong screen.
async function awaitRelease(opts, report) {
const started = Date.now();
while (opts.holdGasEstimate) {
if (Date.now() - started > HOLD_MAX_MS) {
report(
"held gas estimate was never released after " +
HOLD_MAX_MS +
"ms",
);
return;
}
await sleep(HOLD_POLL_MS);
}
}
// One JSON-RPC reply. Methods whose answer depends on a fixture a test has
// set, or on the call itself, are resolved here; every other method is a
// constant in RPC_RESULTS.
function rpcReply(req, opts, report) {
const envelope = { jsonrpc: "2.0", id: req.id };
if (req.method === "eth_getBalance") {
return Object.assign(envelope, {
result: opts.ethBalanceWei || RPC_RESULTS.eth_getBalance,
});
}
if (req.method === "eth_call") {
return Object.assign(envelope, { result: ethCallResult(req) });
}
if (req.method === "eth_getBlockByNumber") {
return Object.assign(envelope, { result: latestBlock() });
}
// The end of the dApp transaction round trip: the raw signed transaction
// the background hands to the node. It is recorded verbatim so a test can
// recover the signer from the exact bytes that were broadcast, rather than
// from anything the extension reported about them.
//
// The reply must be the transaction's real hash. ethers compares the hash
// the node returns against the one it computes itself and throws on a
// mismatch, so a constant here would fail the broadcast for a reason that
// has nothing to do with what is being tested.
if (req.method === "eth_sendRawTransaction") {
const raw = Array.isArray(req.params) ? req.params[0] : null;
let parsed;
try {
parsed = Transaction.from(raw);
} catch {
report("eth_sendRawTransaction with an undecodable transaction");
return Object.assign(envelope, {
error: { code: -32000, message: "undecodable transaction" },
});
}
if (Array.isArray(opts.broadcastTransactions)) {
opts.broadcastTransactions.push(raw);
}
return Object.assign(envelope, { result: parsed.hash });
}
if (req.method === "eth_estimateGas" && opts.failGasEstimate) {
// A refusal the node itself would produce, not a transport error:
// this is the shape the confirmation screen has to turn into
// "Unable to estimate" rather than into a fee of zero.
return Object.assign(envelope, {
error: {
code: -32000,
message: "e2e fixture: gas required exceeds allowance",
},
});
}
const result = RPC_RESULTS[req.method];
if (result === undefined) {
report("unstubbed RPC method: " + req.method);
return Object.assign(envelope, {
error: { code: -32601, message: "unstubbed in e2e harness" },
});
}
return Object.assign(envelope, { result });
}
async function handleRpc(route, postData, opts, report) {
let payload; let payload;
try { try {
payload = JSON.parse(postData || "null"); payload = JSON.parse(postData || "null");
@@ -417,36 +132,34 @@ async function handleRpc(route, postData, opts, report) {
// ethers batches by default, so the body may be an array. // ethers batches by default, so the body may be an array.
const batch = Array.isArray(payload) ? payload : [payload]; const batch = Array.isArray(payload) ? payload : [payload];
// Anything that is not a JSON-RPC object, or a NON-EMPTY batch of // Anything that is not a JSON-RPC object, or a batch of them, is not
// them, is not RPC at all and must be reported like any other // RPC at all and must be reported like any other unrecognised
// unrecognised outbound traffic rather than dereferenced. // outbound traffic rather than dereferenced. request.postData()
// // returns null both for a bodyless POST and for a body Playwright
// The length check is not decoration: every() is vacuously true on an // cannot decode as UTF-8 (sendBeacon with a Blob, or any binary
// empty array, so without it a POST with body [] was answered 200 [] // payload), so this is not an empty-string special case: it rejects
// and escaped the guard entirely (issue #187). No real batch is empty, // every non-object payload, exactly as the catch above rejects every
// so nothing legitimate is caught by it. // unparseable one.
//
// Two distinct paths land a non-RPC body here, and neither is an
// empty-string special case. playwright-core's postData() is
// `buffer.toString("utf-8") || null`, so an absent or empty body
// decodes to null, JSON.parse("null") yields null, and the type guard
// below reports it. A binary body is instead decoded LOSSILY into
// mojibake — not null — which is not valid JSON, so the catch above
// reports that one. Both end up reported; only the route differs.
if ( if (
payload === null || payload === null ||
typeof payload !== "object" || typeof payload !== "object" ||
batch.length === 0 ||
!batch.every((req) => req !== null && typeof req === "object") !batch.every((req) => req !== null && typeof req === "object")
) { ) {
report("unstubbed request: POST " + route.request().url()); report("unstubbed request: POST " + route.request().url());
return route.abort(); return route.abort();
} }
if (batch.some((req) => req.method === "eth_estimateGas")) { const replies = batch.map((req) => {
await awaitRelease(opts, report); const result = RPC_RESULTS[req.method];
if (result === undefined) {
report("unstubbed RPC method: " + req.method);
return {
jsonrpc: "2.0",
id: req.id,
error: { code: -32601, message: "unstubbed in e2e harness" },
};
} }
return { jsonrpc: "2.0", id: req.id, result };
const replies = batch.map((req) => rpcReply(req, opts, report)); });
return jsonResponse(route, Array.isArray(payload) ? replies : replies[0]); return jsonResponse(route, Array.isArray(payload) ? replies : replies[0]);
} }
@@ -486,17 +199,6 @@ function traceEnabled(raw) {
* @param {boolean} [opts.seedTokenTransfer] serve the stubbed ERC-20 * @param {boolean} [opts.seedTokenTransfer] serve the stubbed ERC-20
* transfer. Read at request time, so a test can flip it on the same * transfer. Read at request time, so a test can flip it on the same
* options object without re-registering the route. * options object without re-registering the route.
* @param {boolean} [opts.seedTokenBalance] serve the stubbed ERC-20
* holding, which is what makes the token reachable from the send screen.
* @param {string} [opts.ethBalanceWei] hex wei answered to eth_getBalance;
* defaults to zero, which is what every test that predates the funded
* fixture expects.
* @param {boolean} [opts.failGasEstimate] answer eth_estimateGas with a
* node-side refusal.
* @param {boolean} [opts.holdGasEstimate] hold every batch containing an
* eth_estimateGas until this is cleared again.
* @param {string[]} [opts.broadcastTransactions] every raw signed
* transaction handed to eth_sendRawTransaction, appended in order.
* @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) => * @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) =>
* Promise<string|null>}>} * Promise<string|null>}>}
*/ */
@@ -539,19 +241,7 @@ async function installNetworkStubs(ctx, opts) {
// JSON-RPC endpoint (any host): a POST with a JSON-RPC body. // JSON-RPC endpoint (any host): a POST with a JSON-RPC body.
if (req.method() === "POST") { if (req.method() === "POST") {
return handleRpc(route, req.postData(), opts, report); return handleRpc(route, req.postData(), report);
}
// The local EIP-1193 test page. Served from here so the dApp round
// trips run against a real http(s) origin — which is what makes the
// shipped content scripts inject at all — without any remote origin
// being involved.
if (url.origin === DAPP_ORIGIN && p === "/") {
return route.fulfill({
status: 200,
contentType: "text/html; charset=utf-8",
body: DAPP_HTML,
});
} }
// Blockscout v2 // Blockscout v2
@@ -569,10 +259,7 @@ async function installNetworkStubs(ctx, opts) {
}); });
} }
if (/\/addresses\/0x[0-9a-fA-F]{40}\/token-balances$/.test(p)) { if (/\/addresses\/0x[0-9a-fA-F]{40}\/token-balances$/.test(p)) {
return jsonResponse( return jsonResponse(route, []);
route,
opts.seedTokenBalance ? tokenBalanceItems() : [],
);
} }
if (p.endsWith("/transactions/" + STUB_TX_HASH)) { if (p.endsWith("/transactions/" + STUB_TX_HASH)) {
return jsonResponse(route, transactionDetails()); return jsonResponse(route, transactionDetails());
@@ -642,12 +329,6 @@ async function installNetworkStubs(ctx, opts) {
module.exports = { module.exports = {
installNetworkStubs, installNetworkStubs,
DAPP_HTML,
DAPP_ORIGIN,
DAPP_URL,
FEE_ESTIMATE_WEI,
FEE_RESERVE_WEI,
STUB_COUNTERPARTY,
STUB_TOKEN, STUB_TOKEN,
STUB_TX_HASH, STUB_TX_HASH,
}; };

File diff suppressed because it is too large Load Diff

View File

@@ -247,7 +247,7 @@ describe("a reveal that is not interrupted", () => {
expect(node("export-privkey-value").textContent).toBe(""); expect(node("export-privkey-value").textContent).toBe("");
expect(node("export-privkey-flash").textContent).toBe( expect(node("export-privkey-flash").textContent).toBe(
"That password is incorrect. Please try again.", "That password is not correct. Please try again.",
); );
}); });
}); });

View File

@@ -1,310 +0,0 @@
// The EIP-1193 error the page actually catches (src/content/inpage.js).
//
// The bug this pins down (issue #274): the provider rebuilt every failure as
// `new Error(error.message)`, so the `code` the background produced and the
// content script relayed intact was thrown away in the last hop. A dApp
// checking `err.code === 4001` — the standard way to tell "the user said no"
// from "the wallet broke" — saw undefined, and well-behaved sites showed an
// error or retried instead of accepting the refusal.
//
// inpage.js is a bare IIFE injected into the page's JS context, not a module:
// it takes no import and exports nothing, and reaches for `window` at load.
// So it is evaluated here the way the browser evaluates it, against a stub
// window, and the provider is collected from `window.ethereum`. The globals it
// touches are passed in as function parameters rather than assigned to
// globalThis: nothing leaks between tests, and the source is compiled in this
// realm, so the errors it constructs are comparable against this file's own
// `Error` — which a second realm's intrinsics would silently defeat.
//
// There is no jsdom in this repo; see tests/txStatus.test.js.
const fs = require("fs");
const path = require("path");
const { webcrypto } = require("crypto");
const SOURCE = fs.readFileSync(
path.join(__dirname, "..", "src", "content", "inpage.js"),
"utf8",
);
const loadInto = new Function(
"window",
"self",
"crypto",
"Event",
"CustomEvent",
SOURCE,
);
class StubEvent {
constructor(type) {
this.type = type;
}
}
class StubCustomEvent extends StubEvent {
constructor(type, init) {
super(type);
this.detail = init && init.detail;
}
}
// Every code the background emits on the RPC path today, read out of
// src/background/index.js. The provider must not know this list — it passes
// through whatever arrived — but the cases below are the real ones.
const REJECTED = 4001; // user rejected the request
const UNAUTHORIZED = 4100; // site not connected / wrong address
const UNRECOGNIZED_CHAIN = 4902; // switch/add to an unsupported chain
// A stub window with the four things inpage.js touches: message listeners,
// postMessage out to the content script, window.ethereum, and dispatchEvent
// for the EIP-6963 announcement.
function loadProvider() {
const messageListeners = [];
const posted = [];
const win = {
addEventListener(type, fn) {
if (type === "message") messageListeners.push(fn);
},
removeEventListener(type, fn) {
const i = messageListeners.indexOf(fn);
if (type === "message" && i !== -1) messageListeners.splice(i, 1);
},
postMessage(data) {
posted.push(data);
},
dispatchEvent() {
return true;
},
};
win.window = win;
loadInto(win, win, webcrypto, StubEvent, StubCustomEvent);
// Deliver the content script's answer to an outstanding request. The id is
// read back off the wire rather than assumed: inpage.js issues its own
// eth_chainId at load, so the first id a test sees is not 1.
function respond(response) {
const request = posted
.filter((m) => m.type === "AUTISTMASK_REQUEST")
.pop();
expect(request).toBeDefined();
const event = {
source: win,
data: { type: "AUTISTMASK_RESPONSE", id: request.id, ...response },
};
for (const fn of messageListeners.slice()) fn(event);
}
return { provider: win.ethereum, posted, respond };
}
// Start a request, answer it with `response`, and hand back the rejection.
// Fails the test if the call resolves instead.
async function rejectionFrom(start, response) {
const { provider, respond } = loadProvider();
const settled = start(provider).then(
(result) => ({ resolved: result }),
(error) => ({ error }),
);
// The provider posts synchronously, so the request is already on the wire.
respond(response);
const outcome = await settled;
expect(outcome).not.toHaveProperty("resolved");
return outcome.error;
}
describe("an EIP-1193 code reaches the page", () => {
test("a user rejection arrives as code 4001", async () => {
const err = await rejectionFrom(
(p) => p.request({ method: "eth_requestAccounts" }),
{
error: {
code: REJECTED,
message: "User rejected the request.",
},
},
);
expect(err.code).toBe(REJECTED);
expect(err.message).toBe("User rejected the request.");
});
test("it is a ProviderRpcError, and an Error", async () => {
const err = await rejectionFrom(
(p) => p.request({ method: "eth_requestAccounts" }),
{
error: {
code: REJECTED,
message: "User rejected the request.",
},
},
);
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe("ProviderRpcError");
});
test("4100 unauthorized arrives intact", async () => {
const err = await rejectionFrom(
(p) => p.request({ method: "personal_sign", params: ["0x00"] }),
{ error: { code: UNAUTHORIZED, message: "Unauthorized" } },
);
expect(err.code).toBe(UNAUTHORIZED);
expect(err.message).toBe("Unauthorized");
});
test("4902 unrecognized chain arrives intact", async () => {
const message =
"AutistMask supports Ethereum Mainnet and Sepolia Testnet only.";
const err = await rejectionFrom(
(p) => p.request({ method: "wallet_switchEthereumChain" }),
{ error: { code: UNRECOGNIZED_CHAIN, message } },
);
expect(err.code).toBe(UNRECOGNIZED_CHAIN);
expect(err.message).toBe(message);
});
// The provider is not allowed to know the list above: a code added to the
// background later must reach the page without this file being edited.
test("a code the provider has never heard of is passed through", async () => {
const err = await rejectionFrom(
(p) => p.request({ method: "eth_accounts" }),
{ error: { code: 4900, message: "Disconnected" } },
);
expect(err.code).toBe(4900);
});
test("data is carried when the boundary sent it", async () => {
const err = await rejectionFrom(
(p) => p.request({ method: "eth_call" }),
{
error: {
code: -32000,
message: "execution reverted",
data: "0x08c379a0",
},
},
);
expect(err.code).toBe(-32000);
expect(err.data).toBe("0x08c379a0");
});
test("no data property is invented when the boundary sent none", async () => {
const err = await rejectionFrom(
(p) => p.request({ method: "eth_requestAccounts" }),
{
error: {
code: REJECTED,
message: "User rejected the request.",
},
},
);
expect("data" in err).toBe(false);
});
});
describe("the message is untouched", () => {
test("a coded error keeps the message byte for byte", async () => {
const message =
"This site asked to sign as an address that is not " +
"the active one.";
const err = await rejectionFrom(
(p) => p.request({ method: "personal_sign" }),
{ error: { code: UNAUTHORIZED, message } },
);
expect(err.message).toBe(message);
});
test("an error the background sent with no code keeps its message", async () => {
const err = await rejectionFrom(
(p) => p.request({ method: "eth_sendTransaction" }),
{ error: { message: "No accounts available" } },
);
expect(err.message).toBe("No accounts available");
});
// A ProviderRpcError whose code is undefined would claim a conformance it
// does not have, and `'code' in err` is exactly what a careful dApp asks.
test("an error with no code gets no code property at all", async () => {
const err = await rejectionFrom(
(p) => p.request({ method: "eth_sendTransaction" }),
{ error: { message: "No accounts available" } },
);
expect(err).toBeInstanceOf(Error);
expect("code" in err).toBe(false);
});
test("an error with no message keeps the generic fallback", async () => {
const err = await rejectionFrom(
(p) => p.request({ method: "eth_sendTransaction" }),
{ error: { code: REJECTED } },
);
expect(err.message).toBe("Request failed");
expect(err.code).toBe(REJECTED);
});
});
// Every entry point the provider exposes, not just eth_requestAccounts. They
// all funnel through the same response listener, and this is what says so.
describe("every request path carries the code", () => {
const rejection = {
error: { code: REJECTED, message: "User rejected the request." },
};
test("request()", async () => {
const err = await rejectionFrom(
(p) => p.request({ method: "eth_requestAccounts" }),
rejection,
);
expect(err.code).toBe(REJECTED);
});
test("enable()", async () => {
const err = await rejectionFrom((p) => p.enable(), rejection);
expect(err.code).toBe(REJECTED);
});
test("send(method, params)", async () => {
const err = await rejectionFrom(
(p) => p.send("eth_requestAccounts", []),
rejection,
);
expect(err.code).toBe(REJECTED);
});
test("send({ method, params })", async () => {
const err = await rejectionFrom(
(p) => p.send({ method: "personal_sign", params: ["0x00"] }),
rejection,
);
expect(err.code).toBe(REJECTED);
});
test("sendAsync() hands the code to its callback", async () => {
const { provider, respond } = loadProvider();
const called = new Promise((resolve) => {
provider.sendAsync({ id: 1, method: "eth_requestAccounts" }, (e) =>
resolve(e),
);
});
respond(rejection);
const err = await called;
expect(err.name).toBe("ProviderRpcError");
expect(err.code).toBe(REJECTED);
expect(err.message).toBe("User rejected the request.");
});
});
describe("the success path is unchanged", () => {
test("a result still resolves", async () => {
const { provider, respond } = loadProvider();
const settled = provider.request({ method: "eth_requestAccounts" });
respond({ result: ["0xb61264DEFB0c4B8afb3D73724be15310036743a5"] });
await expect(settled).resolves.toEqual([
"0xb61264DEFB0c4B8afb3D73724be15310036743a5",
]);
expect(provider.selectedAddress).toBe(
"0xb61264DEFB0c4B8afb3D73724be15310036743a5",
);
});
});

View File

@@ -1,213 +0,0 @@
// One wording for one condition (issue #172).
//
// Every screen that asks for the password decrypts the vault itself, and
// each one used to write its own sentence for the same failure: the send
// confirmation and the delete-wallet confirmation said "Wrong password."
// (a fragment, which RULES.md Language & Labeling forbids), the reveal
// screens said "That password is not correct.", and the two dApp approval
// paths said "That password is incorrect." A user hitting two of those
// minutes apart had no way to tell whether the wallet meant the same
// thing.
//
// This scans the source rather than driving six views, because the
// invariant is about the set of call sites and not about any one of them:
// a seventh screen that decrypts the vault has to join the set, and a
// DOM test per view cannot notice one that was never written.
//
// The assertions are per CALL SITE, not per file. approval.js decrypts in
// two places and is where the divergence came from; a per-file check that
// only asks whether the canonical sentence appears somewhere in the file
// passes while one of those two says something else entirely. So each
// call site is read back to its own catch handler and the prose that
// handler shows the user must be the canonical sentence and nothing else
// — which fails on a novel wording, not only on a known-superseded one.
const fs = require("fs");
const path = require("path");
const SRC = path.join(__dirname, "..", "src");
const CANONICAL = "That password is incorrect. Please try again.";
// Wordings this repo has actually shipped for the same condition. This is
// a secondary, whole-file sweep for stragglers outside a decrypt handler;
// divergence at a call site is caught by the exact-match assertion, which
// needs no list of phrasings to guess at.
const SUPERSEDED = [
"Wrong password.",
"That password is not correct. Please try again.",
];
function jsFilesUnder(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return jsFilesUnder(full);
return entry.name.endsWith(".js") ? [full] : [];
});
}
// Blank out the interior of every comment and string literal, keeping the
// offsets and line breaks, so braces can be counted without a quote or a
// commented-out block throwing the count off. The literals are returned
// alongside with the offset of their opening quote, which is how a
// message is later attributed to the handler it sits in.
function scan(source) {
const masked = source.split("");
const strings = [];
const blank = (from, to) => {
for (let k = from; k < to; k++) if (masked[k] !== "\n") masked[k] = " ";
};
let i = 0;
while (i < source.length) {
const two = source.slice(i, i + 2);
if (two === "//") {
const nl = source.indexOf("\n", i);
const stop = nl === -1 ? source.length : nl;
blank(i, stop);
i = stop;
} else if (two === "/*") {
const close = source.indexOf("*/", i + 2);
const stop = close === -1 ? source.length : close + 2;
blank(i, stop);
i = stop;
} else if (
source[i] === '"' ||
source[i] === "'" ||
source[i] === "`"
) {
const quote = source[i];
let j = i + 1;
let value = "";
while (j < source.length && source[j] !== quote) {
if (source[j] === "\\") {
value += source[j + 1];
j += 2;
continue;
}
value += source[j];
j += 1;
}
blank(i + 1, j);
strings.push({ offset: i, value });
i = j + 1;
} else {
i += 1;
}
}
return { masked: masked.join(""), strings };
}
// Offset of the `{` that opens the block containing `at`, or -1.
function enclosingBlockStart(masked, at) {
let depth = 0;
for (let i = at; i >= 0; i--) {
if (masked[i] === "}") depth += 1;
else if (masked[i] === "{") {
if (depth === 0) return i;
depth -= 1;
}
}
return -1;
}
// Offset just past the `}` matching the `{` at `open`.
function blockEnd(masked, open) {
let depth = 0;
for (let i = open; i < masked.length; i++) {
if (masked[i] === "{") depth += 1;
else if (masked[i] === "}") {
depth -= 1;
if (depth === 0) return i + 1;
}
}
throw new Error("unterminated block");
}
// The catch handler guarding a given decryptWithPassword call: walk out to
// the try block the call sits in, then take the catch that follows it.
function handlerSpan(masked, callOffset, label) {
const tryOpen = enclosingBlockStart(masked, callOffset);
if (tryOpen === -1 || !/\btry\s*$/.test(masked.slice(0, tryOpen)))
throw new Error(`${label}: the decrypt is not inside a try block`);
const rest = masked.slice(blockEnd(masked, tryOpen));
const catchMatch = /^\s*catch\s*(\([^)]*\)\s*)?\{/.exec(rest);
if (!catchMatch)
throw new Error(`${label}: the decrypt's try block has no catch`);
const catchOpen = blockEnd(masked, tryOpen) + catchMatch[0].length - 1;
return [catchOpen, blockEnd(masked, catchOpen)];
}
// The prose the handler puts in front of the user. Element ids, class
// names and visibility keywords are single words; a sentence has a space
// in it, and that is the whole distinction needed here.
function handlerMessages(file, callOffset, label) {
const { masked, strings } = scan(fs.readFileSync(file, "utf8"));
const [from, to] = handlerSpan(masked, callOffset, label);
return strings
.filter((s) => s.offset >= from && s.offset < to)
.map((s) => s.value)
.filter((v) => v.includes(" "));
}
// The call sites are found, not listed: the file layout moves (the private
// key export was in addressDetail.js when #172 was filed and is its own
// view now), and a hardcoded list would quietly stop covering a screen it
// no longer names.
function callSites() {
const sites = [];
for (const file of jsFilesUnder(SRC)) {
if (file === path.join(SRC, "shared", "vault.js")) continue;
const { masked } = scan(fs.readFileSync(file, "utf8"));
const rel = path.relative(SRC, file).split(path.sep).join("/");
let n = 0;
let at = masked.indexOf("decryptWithPassword(");
while (at !== -1) {
n += 1;
sites.push({ file, rel, offset: at, label: `${rel} #${n}` });
at = masked.indexOf("decryptWithPassword(", at + 1);
}
}
return sites.sort((a, b) => a.label.localeCompare(b.label));
}
describe("password failure messages", () => {
const sites = callSites();
const files = [...new Set(sites.map((s) => s.file))].sort();
test("the call sites are found where they are expected", () => {
const counts = {};
for (const site of sites)
counts[site.rel] = (counts[site.rel] ?? 0) + 1;
expect(counts).toEqual({
"popup/views/approval.js": 2,
"popup/views/confirmTx.js": 1,
"popup/views/deleteWallet.js": 1,
"popup/views/exportPrivkey.js": 1,
"popup/views/showPhrase.js": 1,
});
});
test("the canonical message is a full sentence", () => {
expect(CANONICAL).toMatch(/^[A-Z][^]*\.$/);
});
// Exact equality, per call site: a message that is merely different
// rather than known-obsolete fails here too, which a scan for historic
// wordings cannot do.
test.each(sites.map((s) => [s.label, s]))(
"%s answers a rejected password with the canonical sentence",
(label, site) => {
expect(handlerMessages(site.file, site.offset, label)).toEqual([
CANONICAL,
]);
},
);
test.each(files.map((f) => [path.relative(SRC, f), f]))(
"%s carries no superseded wording",
(_rel, file) => {
const source = fs.readFileSync(file, "utf8");
for (const old of SUPERSEDED) expect(source).not.toContain(old);
},
);
});

View File

@@ -159,113 +159,3 @@ describe("hideSpoofedSymbols persistence", () => {
expect(second.mod.state.hideSpoofedSymbols).toBe(true); expect(second.mod.state.hideSpoofedSymbols).toBe(true);
}); });
}); });
// restoreView() refuses to reopen ONTO a non-restorable view, but the stack
// behind it was restored verbatim, so Back could still walk onto a screen
// whose content is deliberately never re-rendered — and "show-phrase" has no
// Back control of its own to leave by. The stack is filtered on load, at the
// first entry the popup would not render, and everything above it goes too:
// those entries were reached THROUGH the dropped one.
describe("restored viewStack is filtered against RESTORABLE_VIEWS", () => {
const NON_RESTORABLE = ["export-privkey", "show-phrase"];
function restoredStack(viewStack, currentView = "settings") {
return loadModuleWith({
wallets: oneWallet(),
currentView,
viewStack,
});
}
test("a non-restorable view at the top of the stack is dropped", async () => {
const { mod } = restoredStack(["main", "address", "export-privkey"]);
await mod.loadState();
expect(mod.state.viewStack).toEqual(["main", "address"]);
});
test("a non-restorable view in the middle truncates the stack there", async () => {
const { mod } = restoredStack(["main", "show-phrase", "address"]);
await mod.loadState();
expect(mod.state.viewStack).toEqual(["main"]);
});
// Truncating a stack rooted at a non-restorable view leaves nothing, and
// the restored view still needs somewhere for Back to go.
test("a non-restorable view at the bottom leaves main to go back to", async () => {
const { mod } = restoredStack(["export-privkey", "address", "receive"]);
await mod.loadState();
expect(mod.state.viewStack).toEqual(["main"]);
});
test("no restored stack retains a secret-bearing view", async () => {
for (const view of NON_RESTORABLE) {
const { mod } = restoredStack(["main", "address", view, "receive"]);
await mod.loadState();
expect(mod.state.viewStack).not.toContain(view);
}
});
// The rule is "views the popup will render", not a blocklist of the two
// secret screens: a name no longer in the set (or never a view at all)
// has to go the same way.
test("a name that is not a restorable view at all is dropped", async () => {
const { mod } = restoredStack(["main", "welcome", "address"]);
await mod.loadState();
expect(mod.state.viewStack).toEqual(["main"]);
});
// Restorable entries are kept verbatim. That they are then unhidden
// without being re-rendered is a separate defect, tracked in #268; this
// filter is only about views the popup declined to restore.
test("an ordinary restorable stack is restored unchanged", async () => {
const stack = ["main", "address", "address-token"];
const { mod } = restoredStack(stack);
await mod.loadState();
expect(mod.state.viewStack).toEqual(stack);
});
test("restoring onto main keeps the stack empty", async () => {
const { mod } = restoredStack(["show-phrase"], "main");
await mod.loadState();
expect(mod.state.viewStack).toEqual([]);
});
// main is not the only view that gets no ["main"] beneath it: restoreView()
// will not reopen onto a non-restorable view either, so nothing is left for
// Back to sit under and the stack stays empty.
test("restoring onto a view the popup will not reopen keeps the stack empty", async () => {
const { mod } = restoredStack(["export-privkey"], "show-phrase");
await mod.loadState();
expect(mod.state.viewStack).toEqual([]);
});
// Not an array means nothing survives, but the never-empty rule still
// applies: a corrupt stack must not leave a restored view with no Back
// target of its own.
test("a stack that is not an array still gets main beneath a restored view", async () => {
const { mod } = restoredStack("main");
await mod.loadState();
expect(mod.state.viewStack).toEqual(["main"]);
});
test("a stack that is not an array loads as empty under main", async () => {
const { mod } = restoredStack({ 0: "main" }, "main");
await mod.loadState();
expect(mod.state.viewStack).toEqual([]);
});
// Filtering belongs on load, not on save: the live in-session stack is
// legitimate — the user really is one Back away from a screen that is
// rendered right now — and only a load-side filter also cleans the
// stacks already sitting in storage.
test("saveState persists the live stack verbatim", async () => {
const { mod, set } = loadModuleWith(null);
mod.state.viewStack = ["main", "address", "export-privkey"];
await mod.saveState();
expect(set).toHaveBeenCalledWith({
autistmask: expect.objectContaining({
viewStack: ["main", "address", "export-privkey"],
}),
});
});
});

View File

@@ -1,693 +0,0 @@
// Tests for the known-symbol spoof rule (src/shared/symbolSpoof.js) and for
// its application on all three surfaces that show tokens: the transaction
// history, the Send token selector, and the balance list.
//
// Issue #235: the three surfaces disagreed about what a `null` entry in
// KNOWN_SYMBOLS means. The history and the selector read it as "no contract
// may bear this symbol" and filtered a fake `ETH` ERC-20; the balance list
// read it as "no comparison is possible" and listed the fake token next to
// the user's real ETH, which is where a user forms their belief about what
// they own. The rule now lives in one module, so a fourth surface cannot
// reintroduce a fourth reading, and these tests assert the same attack on
// each surface.
//
// Nothing here touches the network: global.fetch is a throwing stub and the
// only fetch path in the modules under test (debugFetch, from
// src/shared/log) is mocked at the module boundary.
// The RPC provider is replaced so that refreshBalances can be driven end to
// end: the native balance it reports must survive a balance list in which
// every ERC-20 row is a fake ETH. Everything else in ethers is the real
// module, including the formatters the assertions depend on.
jest.mock("ethers", () => {
const actual = jest.requireActual("ethers");
class StubProvider {
async getBalance() {
return 1234500000000000000n;
}
async lookupAddress() {
return null;
}
}
return {
...actual,
JsonRpcProvider: StubProvider,
Network: { from: () => ({}) },
};
});
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
debugFetch: jest.fn(),
setRuntimeDebug: () => {},
isDebug: () => false,
}));
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
global.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { isSpoofedSymbol } = require("../src/shared/symbolSpoof");
const { TOKENS, KNOWN_SYMBOLS } = require("../src/shared/tokenList");
const { filterTransactions } = require("../src/shared/transactions");
const {
fetchTokenBalances,
refreshBalances,
} = require("../src/shared/balances");
const { renderSendTokenSelect } = require("../src/popup/views/send");
const { state } = require("../src/shared/state");
const { debugFetch } = require("../src/shared/log");
// The fake "Ethereum" token with symbol "ETH" from the attack documented in
// README.md, given a holder count high enough to clear every other filter so
// that only the known-symbol rule can catch it.
const FAKE_ETH_CONTRACT = "0xd05339f9ea5ab9d9f03b9d57f671d2abd1f55c82";
const HOLDER = "0x66133e8ea0f5d1d612d2502a968757d1048c214a";
const USDC_CONTRACT = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
const WETH_CONTRACT = "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2";
const BLOCKSCOUT = "https://eth.blockscout.com/api/v2";
describe("the shared rule", () => {
test('"ETH" is still the null-mapped symbol these tests assume', () => {
expect(KNOWN_SYMBOLS.get("ETH")).toBeNull();
});
test("a contract bearing a null-mapped symbol is a spoof", () => {
expect(isSpoofedSymbol("ETH", FAKE_ETH_CONTRACT)).toBe(true);
});
test("even a genuine contract may not bear a null-mapped symbol", () => {
expect(isSpoofedSymbol("ETH", WETH_CONTRACT)).toBe(true);
});
test("the native asset carries no contract and is never a spoof", () => {
expect(isSpoofedSymbol("ETH", null)).toBe(false);
expect(isSpoofedSymbol("ETH", undefined)).toBe(false);
expect(isSpoofedSymbol("ETH", "")).toBe(false);
});
// The native exemption is "has no contract address", not "the symbol is
// ETH". A second null-mapped symbol added to the table later inherits
// both halves of the rule without any call site being revisited.
test("a newly null-mapped symbol behaves the same way", () => {
const added = !KNOWN_SYMBOLS.has("XTZTEST");
KNOWN_SYMBOLS.set("XTZTEST", null);
try {
expect(isSpoofedSymbol("XTZTEST", FAKE_ETH_CONTRACT)).toBe(true);
expect(isSpoofedSymbol("XTZTEST", null)).toBe(false);
} finally {
if (added) KNOWN_SYMBOLS.delete("XTZTEST");
}
});
test("a known symbol from its own contract is not a spoof", () => {
expect(isSpoofedSymbol("USDC", USDC_CONTRACT)).toBe(false);
expect(isSpoofedSymbol("usdc", USDC_CONTRACT.toUpperCase())).toBe(
false,
);
});
test("a known symbol from another contract is a spoof", () => {
expect(isSpoofedSymbol("USDC", FAKE_ETH_CONTRACT)).toBe(true);
});
test("a symbol that is not in the table is not judged here", () => {
expect(isSpoofedSymbol("SPAMTKN", FAKE_ETH_CONTRACT)).toBe(false);
});
});
// Issue #260: the symbol is whatever the ERC-20 contract returns, and HTML
// collapses leading and trailing whitespace, so a token calling itself
// `" ETH "` reaches the user's eye as `ETH` while missing a raw
// KNOWN_SYMBOLS lookup. Normalizing inside the shared rule fixes all three
// surfaces at once, which is what consolidating the rule bought.
//
// Every character under test here is built from its code point rather than
// pasted in: most of them are invisible, and an invisible character in a
// test file is unreviewable.
const cp = (...codes) => String.fromCodePoint(...codes);
const NBSP = cp(0x00a0); // no-break space
const FIGURE_SPACE = cp(0x2007);
const IDEOGRAPHIC_SPACE = cp(0x3000);
const ZWSP = cp(0x200b); // zero-width space
const BOM = cp(0xfeff); // zero-width no-break space
const WORD_JOINER = cp(0x2060);
const SOFT_HYPHEN = cp(0x00ad);
const LRM = cp(0x200e); // left-to-right mark
const RLO = cp(0x202e); // right-to-left override
const HANGUL_FILLER = cp(0x3164);
const CHOSEONG_FILLER = cp(0x115f);
const VS16 = cp(0xfe0f); // variation selector-16
const VS1 = cp(0xfe00); // variation selector-1
const NEL = cp(0x0085); // next line, a C1 control
const DEL = cp(0x007f);
const FULLWIDTH_ETH = cp(0xff25, 0xff34, 0xff28);
const FULLWIDTH_USDC = cp(0xff55, 0xff53, 0xff44, 0xff43); // lowercase
const CYRILLIC_CAPITAL_IE = cp(0x0415);
describe("the shared rule: symbols that render as a known symbol", () => {
test("ASCII padding does not buy a pass", () => {
expect(isSpoofedSymbol(" ETH ", FAKE_ETH_CONTRACT)).toBe(true);
expect(isSpoofedSymbol("\tETH\n", FAKE_ETH_CONTRACT)).toBe(true);
expect(isSpoofedSymbol(" usdc ", FAKE_ETH_CONTRACT)).toBe(true);
});
test("non-breaking and other Unicode spaces do not either", () => {
expect(isSpoofedSymbol(NBSP + "ETH" + NBSP, FAKE_ETH_CONTRACT)).toBe(
true,
);
expect(
isSpoofedSymbol(
FIGURE_SPACE + "ETH" + IDEOGRAPHIC_SPACE,
FAKE_ETH_CONTRACT,
),
).toBe(true);
});
// These render as nothing at all, in any position, so they are removed
// wherever they sit rather than only at the ends.
test("zero-width characters are stripped wherever they sit", () => {
expect(isSpoofedSymbol("E" + ZWSP + "TH", FAKE_ETH_CONTRACT)).toBe(
true,
);
expect(isSpoofedSymbol(BOM + "ETH", FAKE_ETH_CONTRACT)).toBe(true);
expect(
isSpoofedSymbol("ET" + WORD_JOINER + "H", FAKE_ETH_CONTRACT),
).toBe(true);
expect(
isSpoofedSymbol("E" + SOFT_HYPHEN + "TH", FAKE_ETH_CONTRACT),
).toBe(true);
});
// An LRM is invisible and, in all-Latin text, moves nothing: dropping it
// leaves exactly the string the user saw.
test("an invisible bidi mark does not hide a known symbol", () => {
expect(isSpoofedSymbol(LRM + "ETH", FAKE_ETH_CONTRACT)).toBe(true);
});
// Invisibility is not confined to \p{Cf}. A Hangul filler is Lo and a
// variation selector is Mn, yet each of these four measures 32.00px in
// the repo's pinned e2e Chromium at 16px sans-serif — exactly the width
// of a plain `ETH` — so each reaches the user's eye as `ETH`. They are
// caught by \p{Default_Ignorable_Code_Point}, not by \p{Cf}.
test("invisible non-format characters are stripped too", () => {
expect(isSpoofedSymbol(HANGUL_FILLER + "ETH", FAKE_ETH_CONTRACT)).toBe(
true,
);
expect(
isSpoofedSymbol(CHOSEONG_FILLER + "ETH", FAKE_ETH_CONTRACT),
).toBe(true);
expect(isSpoofedSymbol("ETH" + VS16, FAKE_ETH_CONTRACT)).toBe(true);
expect(isSpoofedSymbol("E" + VS1 + "TH", FAKE_ETH_CONTRACT)).toBe(true);
});
// Nor is it confined to the Unicode classes. U+007F is a control (Cc)
// and is not default-ignorable, so neither class reaches it, but it
// measures 32.00px in the same browser — it paints nothing, so a
// symbol carrying it reaches the eye as `ETH`. It is named on its own
// in the strip for exactly that reason.
test("U+007F paints nothing and is stripped", () => {
expect(isSpoofedSymbol(DEL + "ETH", FAKE_ETH_CONTRACT)).toBe(true);
});
// The other side of the boundary, which is not the class boundary but
// the visibility one: the remaining C0 and C1 controls render as a
// visible 48.00px box in the same browser, so a symbol carrying one
// does not look like `ETH` and must not be judged a spoof. Widening
// the strip to \p{Cc} — the obvious over-correction once U+007F is in
// it — fails this test.
test("visible control characters do not make a symbol a spoof", () => {
expect(isSpoofedSymbol(NEL + "ETH", FAKE_ETH_CONTRACT)).toBe(false);
expect(isSpoofedSymbol(cp(0x0001) + "ETH", FAKE_ETH_CONTRACT)).toBe(
false,
);
expect(isSpoofedSymbol(cp(0x0090) + "ETH", FAKE_ETH_CONTRACT)).toBe(
false,
);
});
test("compatibility forms fold onto the symbol they imitate", () => {
expect(isSpoofedSymbol(FULLWIDTH_ETH, FAKE_ETH_CONTRACT)).toBe(true);
expect(isSpoofedSymbol(FULLWIDTH_USDC, FAKE_ETH_CONTRACT)).toBe(true);
});
// The two knowingly open classes, asserted here so that the boundary is
// a fact in the suite and not a claim in a PR body. A Cyrillic capital
// Ie is a distinct letter rather than a compatibility variant, so NFKC
// leaves it alone; and a right-to-left override reverses the rendering
// of what follows it, which dropping the control character does not
// undo. Closing either needs a confusables table or a bidi resolver,
// and both are a separate change from this one.
test("a Cyrillic homoglyph is knowingly still not caught", () => {
expect(
isSpoofedSymbol(CYRILLIC_CAPITAL_IE + "TH", FAKE_ETH_CONTRACT),
).toBe(false);
});
test("a bidi-reordered symbol is knowingly still not caught", () => {
expect(isSpoofedSymbol(RLO + "HTE", FAKE_ETH_CONTRACT)).toBe(false);
});
// Normalization does not reach the native-asset exemption, which turns
// on the absence of a contract address and never on the symbol.
test("a padded symbol with no contract is still not a spoof", () => {
expect(isSpoofedSymbol(" ETH ", null)).toBe(false);
expect(isSpoofedSymbol(NBSP + "ETH", "")).toBe(false);
});
test("a genuine contract still bears its own padded symbol", () => {
expect(isSpoofedSymbol(" USDC ", USDC_CONTRACT)).toBe(false);
expect(isSpoofedSymbol(ZWSP + "WETH", WETH_CONTRACT)).toBe(false);
});
// Normalization must not invent a match. Interior ASCII whitespace is
// left alone: `E T H` renders as `E T H`, not as `ETH`, so folding it
// would filter a token no user could confuse with the native asset.
test("a symbol that renders differently is not judged a spoof", () => {
expect(isSpoofedSymbol("E T H", FAKE_ETH_CONTRACT)).toBe(false);
expect(isSpoofedSymbol("ETH2", FAKE_ETH_CONTRACT)).toBe(false);
expect(isSpoofedSymbol("MY ETH", FAKE_ETH_CONTRACT)).toBe(false);
});
// The false-positive question, answered against the shipped data rather
// than by assertion: no bundled symbol carries whitespace or a
// non-ASCII character, so the normalization cannot newly filter one.
// The character class starts at `!` rather than at the space so that it
// asserts the claim it stands for — `[ -~]` would admit an interior
// space and let a whitespace-bearing entry through the guard.
test("no bundled symbol is touched by the normalization", () => {
for (const [symbol, addresses] of KNOWN_SYMBOLS) {
expect(symbol).toBe(symbol.trim());
expect(symbol).toMatch(/^[!-~]+$/);
if (addresses === null) continue;
for (const address of addresses) {
expect(isSpoofedSymbol(symbol, address)).toBe(false);
}
}
});
});
// Issue #276: the guard that was missing. The suite walked KNOWN_SYMBOLS,
// which is built from TOKENS, so it could only ever assert that the table
// agrees with itself. Seven symbols appear twice in the bundled list at two
// different real contracts, and the table kept whichever came first, so the
// other seven contracts — tokens in our own shipped list, at their own
// addresses — were judged spoofs and hidden from the balance list, the
// history and the send selector. That is the over-filtering direction: it
// hides a holding the user cannot then spend.
//
// This walk is over TOKENS, the data the wallet actually ships, so it fails
// whenever a bundled token would be filtered at its own address no matter
// which side of the table the mistake is on.
describe("the shipped token list", () => {
test("no bundled token is filtered at its own address", () => {
const filtered = TOKENS.filter((t) =>
isSpoofedSymbol(t.symbol, t.address),
).map((t) => t.symbol + " @ " + t.address);
expect(filtered).toEqual([]);
});
// The third failure mode the issue asks about: a symbol whose table entry
// names an address that is in neither the table nor the list would be a
// contract we vouch for and do not ship. There is none, and the table is
// built from the list, so this asserts the derivation has not acquired a
// hand-written entry.
test("every address the table vouches for is a bundled token", () => {
const bundled = new Set(TOKENS.map((t) => t.address.toLowerCase()));
for (const [symbol, addresses] of KNOWN_SYMBOLS) {
if (addresses === null) continue;
expect(addresses.size).toBeGreaterThan(0);
for (const address of addresses) {
expect(address).toBe(address.toLowerCase());
expect(bundled.has(address)).toBe(true);
// And it is the token that actually reports that symbol.
const token = TOKENS.find(
(t) => t.address.toLowerCase() === address,
);
expect(token.symbol.toUpperCase()).toBe(symbol);
}
}
});
// Both contracts behind a shared ticker must pass, from either side: a
// rule that admits only the one the table happens to visit first is the
// bug, not the fix.
test("both contracts behind a shared ticker are admitted", () => {
const bySymbol = new Map();
for (const t of TOKENS) {
const upper = t.symbol.toUpperCase();
if (!bySymbol.has(upper)) bySymbol.set(upper, []);
bySymbol.get(upper).push(t);
}
const shared = [...bySymbol].filter(([, list]) => list.length > 1);
// The shared tickers are a fact about the shipped data; if a future
// list has none, this test would silently assert nothing.
expect(shared.length).toBeGreaterThan(0);
for (const [, list] of shared) {
for (const t of list) {
expect(isSpoofedSymbol(t.symbol, t.address)).toBe(false);
}
}
});
// The seven from issue #276, named so that the reconciliation is a fact
// in the suite: each is two real contracts from the same source fetch,
// and the table now holds both rather than the one that came first.
test("the seven shared tickers each name both bundled contracts", () => {
const expected = {
TON: [
"0x582d872a1b094fc48f5de31d3b73f2d9be47def1", // Toncoin
"0x2be5e8c109e2197d077d13a82daead6a9b3433c5", // Tokamak Network
],
FRAX: [
"0x853d955acef822db058eb8505911ed77f175b99e", // Legacy Frax Dollar
"0x3432b6a60d23ca0dfca7761b7ab56459d9c964d0", // Frax (prev. FXS)
],
REUSD: [
"0x5086bf358635b81d8c47c66d1c8b9e567db70c72", // Re Protocol reUSD
"0x57ab1e0003f623289cd798b1824be09a793e4bec", // Resupply USD
],
EURE: [
"0x39b8b6385416f4ca36a20319f70d28621895279d", // Monerium EUR emoney
"0x3231cb76718cdef2155fc47b5286d82e6eda273f", // Monerium EUR emoney [OLD]
],
MSUSD: [
"0x4ba01f22827018b4772cd326c7627fb4956a7c00", // Main Street USD
"0xab5eb14c09d416f0ac63661e57edb7aecdb9befa", // Metronome Synth USD
],
MUSD: [
"0xaca92e438df0b2401ff60da7e4337b687a2435da", // MetaMask USD
"0xdd468a1ddc392dcdbef6db6e34e89aa338f9f186", // Mezo USD
],
JPYC: [
"0x431d5dff03120afa4bdf332c61a6e1766ef37bdb", // JPY Coin
"0x2370f9d504c7a6e775bf6e14b3f12846b594cd53", // JPY Coin v1
],
};
for (const [symbol, addresses] of Object.entries(expected)) {
expect([...KNOWN_SYMBOLS.get(symbol)].sort()).toEqual(
[...addresses].sort(),
);
for (const address of addresses) {
expect(isSpoofedSymbol(symbol, address)).toBe(false);
}
}
});
// The other direction, on the same symbols: widening the table to hold
// every bundled address for a ticker must not turn it into a pass for
// any other contract.
test("a shared ticker from a third contract is still a spoof", () => {
const bySymbol = new Map();
for (const t of TOKENS) {
const upper = t.symbol.toUpperCase();
if (!bySymbol.has(upper)) bySymbol.set(upper, []);
bySymbol.get(upper).push(t);
}
for (const [symbol, list] of bySymbol) {
if (list.length < 2) continue;
expect(isSpoofedSymbol(symbol, FAKE_ETH_CONTRACT)).toBe(true);
}
});
});
describe("surface 1: the transaction history", () => {
function fakeEthTransfer() {
return {
hash: "0x" + "1".repeat(64),
symbol: "ETH",
contractAddress: FAKE_ETH_CONTRACT,
holders: 900000,
valueGwei: null,
isContractCall: false,
};
}
test("a fake ETH token transfer is filtered", () => {
const result = filterTransactions([fakeEthTransfer()], {
hideSpoofedSymbols: true,
hideFraudContracts: true,
hideLowHolderTokens: true,
hideDustTransactions: true,
dustThresholdGwei: 100000,
});
expect(result.transactions).toEqual([]);
});
// Issue #260 on this surface: the same transfer with a padded symbol.
test("a padded fake ETH token transfer is filtered too", () => {
const padded = { ...fakeEthTransfer(), symbol: " ETH " };
const result = filterTransactions([padded], {
hideSpoofedSymbols: true,
hideFraudContracts: true,
hideLowHolderTokens: true,
hideDustTransactions: true,
dustThresholdGwei: 100000,
});
expect(result.transactions).toEqual([]);
// The contract is learned as fraudulent, exactly as for the
// unpadded symbol: the padding must not cost the blocklist entry.
expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]);
});
test("a real native ETH transfer survives", () => {
const native = {
hash: "0x" + "2".repeat(64),
symbol: "ETH",
contractAddress: null,
holders: null,
valueGwei: 5000000,
isContractCall: false,
};
const result = filterTransactions([native], {
hideSpoofedSymbols: true,
hideFraudContracts: true,
hideLowHolderTokens: true,
hideDustTransactions: true,
dustThresholdGwei: 100000,
});
expect(result.transactions).toEqual([native]);
});
});
describe("surface 2: the Send token selector", () => {
let select;
function render(tokenBalances) {
select = { innerHTML: "", children: [] };
select.appendChild = (child) => select.children.push(child);
globalThis.document = {
getElementById: (id) => (id === "send-token" ? select : null),
createElement: () => ({ value: "", textContent: "" }),
};
renderSendTokenSelect({
address: "0x" + "a".repeat(40),
tokenBalances,
});
}
beforeEach(() => {
state.fraudContracts = [];
state.hideLowHolderTokens = true;
});
test("a fake ETH token is not selectable", () => {
render([
{
address: FAKE_ETH_CONTRACT,
symbol: "ETH",
decimals: 18,
balance: "0.005",
holders: 900000,
},
]);
expect(select.children).toEqual([]);
});
// Issue #260 on this surface: the option text is rendered into HTML,
// which collapses the padding, so an unfiltered padded token would sit
// in the selector reading exactly `ETH`.
test("a padded fake ETH token is not selectable either", () => {
render([
{
address: FAKE_ETH_CONTRACT,
symbol: " ETH ",
decimals: 18,
balance: "0.005",
holders: 900000,
},
]);
expect(select.children).toEqual([]);
});
test("a genuine token with a padded symbol stays selectable", () => {
render([
{
address: USDC_CONTRACT,
symbol: " USDC ",
decimals: 6,
balance: "12.5",
holders: 900000,
},
]);
expect(select.children).toHaveLength(1);
expect(select.children[0].value).toBe(USDC_CONTRACT);
});
test("native ETH remains the always-present option", () => {
render([]);
expect(select.innerHTML).toBe('<option value="ETH">ETH</option>');
});
});
describe("surface 3: the balance list", () => {
function respondWith(items) {
debugFetch.mockImplementation(async () => ({
ok: true,
status: 200,
statusText: "OK",
json: async () => items,
}));
}
function fakeEthItem(overrides = {}) {
return {
value: "5000000000000000",
token: {
type: "ERC-20",
address_hash: FAKE_ETH_CONTRACT,
symbol: "ETH",
name: "Ethereum",
decimals: "18",
holders_count: "900000",
...overrides,
},
};
}
beforeEach(() => {
debugFetch.mockReset();
});
// The bug in issue #235: this token cleared the balance list's own
// 1,000-holder floor and was listed as a holding named ETH.
test("a fake ETH token clearing the holder floor is filtered", async () => {
respondWith([fakeEthItem()]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
});
test("tracking the fake token manually does not admit it either", async () => {
respondWith([fakeEthItem({ holders_count: "0" })]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, [
{ address: FAKE_ETH_CONTRACT },
]);
expect(balances).toEqual([]);
});
// Issue #260 on this surface: the balance list is where the user forms
// their belief about what they own, and it renders the symbol into HTML.
test("a padded fake ETH token is filtered too", async () => {
respondWith([fakeEthItem({ symbol: " ETH " })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
});
test("a fake ETH token padded with a no-break space is filtered", async () => {
respondWith([fakeEthItem({ symbol: NBSP + "ETH" + NBSP })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
});
// The false-positive direction on the surface that matters most: a real
// holding whose symbol happens to carry padding is still listed, and the
// list still shows the symbol the token actually reports.
test("a genuine token with a padded symbol is not newly filtered", async () => {
respondWith([
fakeEthItem({
address_hash: USDC_CONTRACT,
symbol: " USDC ",
name: "USD Coin",
decimals: "6",
}),
]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
expect(balances).toHaveLength(1);
expect(balances[0].symbol).toBe(" USDC ");
});
test("a genuine token keeps its place in the list", async () => {
respondWith([
fakeEthItem({
address_hash: USDC_CONTRACT,
symbol: "USDC",
name: "USD Coin",
decimals: "6",
}),
]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
expect(balances).toHaveLength(1);
expect(balances[0].symbol).toBe("USDC");
});
// The trap in this change: the user's real ETH balance is not an ERC-20
// and is fetched over RPC in refreshBalances, so it never passes through
// this loop at all. An explorer row that is not an ERC-20 is dropped
// before the symbol rule is consulted.
test("a non-ERC-20 row claiming ETH never reaches the symbol rule", async () => {
respondWith([fakeEthItem({ type: "ERC-721" })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
});
// The adjacent finding from the same review as issue #260: the type gate
// compared exactly, so an explorer that ever varied the casing would
// silently drop a real holding before any filter ran. The comparison is
// now case-insensitive, which changes nothing about which types are
// admitted.
test("a differently-cased ERC-20 type still lists a real holding", async () => {
respondWith([
fakeEthItem({
type: "erc-20",
address_hash: USDC_CONTRACT,
symbol: "USDC",
name: "USD Coin",
decimals: "6",
}),
]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
expect(balances).toHaveLength(1);
expect(balances[0].symbol).toBe("USDC");
});
test("case insensitivity does not admit another token type", async () => {
respondWith([fakeEthItem({ type: "erc-721" })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
respondWith([fakeEthItem({ type: "ERC-20-EXTRA" })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
});
// The money test: the user holds real ETH and has been airdropped a fake
// ETH ERC-20. The fake is gone from the list of tokens; the real balance
// is exactly what the node reported.
test("the real native ETH balance survives a fake ETH airdrop", async () => {
respondWith([fakeEthItem()]);
const addr = { address: HOLDER };
await refreshBalances(
[{ addresses: [addr] }],
"https://rpc.example.invalid",
BLOCKSCOUT,
[],
);
expect(addr.balance).toBe("1.2345");
expect(addr.tokenBalances).toEqual([]);
});
test("no test in this file performed a network request", () => {
expect(global.fetch).not.toHaveBeenCalled();
});
});

View File

@@ -207,8 +207,8 @@ describe("token list assumptions the fixtures rely on", () => {
}); });
test("USDC and WETH map to their genuine lowercased contracts", () => { test("USDC and WETH map to their genuine lowercased contracts", () => {
expect([...KNOWN_SYMBOLS.get("USDC")]).toEqual([USDC_CONTRACT]); expect(KNOWN_SYMBOLS.get("USDC")).toBe(USDC_CONTRACT);
expect([...KNOWN_SYMBOLS.get("WETH")]).toEqual([WETH_CONTRACT]); expect(KNOWN_SYMBOLS.get("WETH")).toBe(WETH_CONTRACT);
}); });
test("the spam fixture symbol is not in the known token list", () => { test("the spam fixture symbol is not in the known token list", () => {

View File

@@ -1,6 +1,4 @@
const { const {
canRemoveAddress,
removeAddressFromState,
removeWalletFromState, removeWalletFromState,
broadcastActiveChanged, broadcastActiveChanged,
} = require("../src/shared/walletDelete"); } = require("../src/shared/walletDelete");
@@ -8,7 +6,6 @@ const {
// Fixed addresses — never used for anything but these tests. // Fixed addresses — never used for anything but these tests.
const A0 = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; const A0 = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const A1 = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; const A1 = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const A2 = "0x514910771AF9Ca656af840dff83E8264EcF986CA";
const B0 = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599"; const B0 = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599";
const C0 = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; const C0 = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
@@ -114,219 +111,6 @@ describe("removeWalletFromState", () => {
}); });
}); });
// An HD wallet with three addresses next to a single-address key wallet.
// `nextIndex` is the wallet's derivation high-water mark, three addresses in.
function makeAddressState(overrides = {}) {
return {
hasWallet: true,
wallets: [
{ ...wallet("A", [A0, A1, A2]), type: "hd", nextIndex: 3 },
{ ...wallet("B", [B0]), type: "key" },
],
selectedWallet: 0,
selectedAddress: 0,
activeAddress: A0,
allowedSites: { [A0]: ["a.example"], [A1]: ["b.example"] },
deniedSites: { [A1]: ["d.example"], [B0]: ["e.example"] },
...overrides,
};
}
describe("canRemoveAddress", () => {
test("an HD wallet with more than one address may remove one", () => {
expect(canRemoveAddress({ type: "hd", addresses: [{}, {}] })).toBe(
true,
);
});
test("an xprv wallet with more than one address may too", () => {
expect(canRemoveAddress({ type: "xprv", addresses: [{}, {}] })).toBe(
true,
);
});
// The last address is what delete-wallet is for.
test("a wallet holding a single address may not", () => {
expect(canRemoveAddress({ type: "hd", addresses: [{}] })).toBe(false);
});
// A key wallet holds one bare private key and cannot derive more, so it
// has no "+" button and gets no remove control either.
test("a key wallet may not, whatever its address count", () => {
expect(canRemoveAddress({ type: "key", addresses: [{}] })).toBe(false);
expect(canRemoveAddress({ type: "key", addresses: [{}, {}] })).toBe(
false,
);
});
test("a missing or typeless wallet may not", () => {
expect(canRemoveAddress(undefined)).toBe(false);
expect(canRemoveAddress({})).toBe(false);
});
});
describe("removeAddressFromState", () => {
test("removing a non-selected address leaves the selection where it is", () => {
const state = makeAddressState({
selectedAddress: 2,
activeAddress: A2,
});
const { removed, activeAddressChanged } = removeAddressFromState(
state,
0,
0,
);
expect(removed).toBe(true);
// A2 moved from index 2 to index 1 by the splice.
expect(state.wallets[0].addresses.map((a) => a.address)).toEqual([
A1,
A2,
]);
expect(state.selectedWallet).toBe(0);
expect(state.selectedAddress).toBe(1);
expect(state.activeAddress).toBe(A2);
expect(activeAddressChanged).toBe(false);
// The wallet list itself is untouched.
expect(state.wallets).toHaveLength(2);
expect(state.hasWallet).toBe(true);
});
test("removing an address after the selection does not shift it", () => {
const state = makeAddressState({
selectedAddress: 0,
activeAddress: A0,
});
const { removed, activeAddressChanged } = removeAddressFromState(
state,
0,
2,
);
expect(removed).toBe(true);
expect(state.selectedAddress).toBe(0);
expect(state.activeAddress).toBe(A0);
expect(activeAddressChanged).toBe(false);
});
test("a selection in another wallet is untouched", () => {
const state = makeAddressState({
selectedWallet: 1,
selectedAddress: 0,
activeAddress: B0,
});
const { removed, activeAddressChanged } = removeAddressFromState(
state,
0,
1,
);
expect(removed).toBe(true);
expect(state.selectedWallet).toBe(1);
expect(state.selectedAddress).toBe(0);
expect(state.activeAddress).toBe(B0);
expect(activeAddressChanged).toBe(false);
});
test("removing the selected address falls back to the wallet's first address", () => {
const state = makeAddressState({
selectedAddress: 1,
activeAddress: A1,
});
const { removed, activeAddressChanged } = removeAddressFromState(
state,
0,
1,
);
expect(removed).toBe(true);
expect(state.wallets[0].addresses.map((a) => a.address)).toEqual([
A0,
A2,
]);
expect(state.selectedWallet).toBe(0);
expect(state.selectedAddress).toBe(0);
expect(state.activeAddress).toBe(A0);
expect(activeAddressChanged).toBe(true);
});
// The active address can be persisted in a different case than the
// wallet's copy of it, so the comparison must not be literal.
test("the active address is matched case-insensitively", () => {
const state = makeAddressState({
selectedAddress: 1,
activeAddress: A1.toLowerCase(),
});
const { activeAddressChanged } = removeAddressFromState(state, 0, 1);
expect(state.activeAddress).toBe(A0);
expect(activeAddressChanged).toBe(true);
});
test("site permissions are dropped for the removed address only", () => {
const state = makeAddressState();
removeAddressFromState(state, 0, 1);
expect(state.allowedSites).toEqual({ [A0]: ["a.example"] });
expect(state.deniedSites).toEqual({ [B0]: ["e.example"] });
});
// The derivation counter is a high-water mark, never rewound: "+" derives
// a fresh index rather than re-deriving the address just removed.
test("the wallet's derivation counter is not rewound", () => {
const state = makeAddressState();
removeAddressFromState(state, 0, 1);
expect(state.wallets[0].nextIndex).toBe(3);
});
test("the last address of a wallet is refused, and nothing changes", () => {
const state = makeAddressState({
selectedWallet: 1,
selectedAddress: 0,
activeAddress: B0,
});
const { removed, activeAddressChanged } = removeAddressFromState(
state,
1,
0,
);
expect(removed).toBe(false);
expect(activeAddressChanged).toBe(false);
expect(state.wallets[1].addresses.map((a) => a.address)).toEqual([B0]);
expect(state.activeAddress).toBe(B0);
expect(state.hasWallet).toBe(true);
});
// The same refusal reached the other way: an HD wallet worn down to one
// address is no more removable than a key wallet.
test("an HD wallet down to its last address is refused too", () => {
const state = makeAddressState();
expect(removeAddressFromState(state, 0, 2).removed).toBe(true);
expect(removeAddressFromState(state, 0, 1).removed).toBe(true);
expect(removeAddressFromState(state, 0, 0).removed).toBe(false);
expect(state.wallets[0].addresses.map((a) => a.address)).toEqual([A0]);
});
test("an out-of-range address index is refused", () => {
const state = makeAddressState();
expect(removeAddressFromState(state, 0, 7).removed).toBe(false);
expect(removeAddressFromState(state, 7, 0).removed).toBe(false);
expect(state.wallets[0].addresses).toHaveLength(3);
});
});
describe("broadcastActiveChanged", () => { describe("broadcastActiveChanged", () => {
afterEach(() => { afterEach(() => {
delete global.chrome; delete global.chrome;