Compare commits

..
Author SHA1 Message Date
sneak 3eddb12569 test: the Firefox suite tolerates no extension error (closes #487)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
The one entry in ALLOWED_ERRORS in tests/e2e/firefox/run.js, Firefox
reporting a popup promise that settled after the page unloaded, lost its
cause when the site-connection buttons stopped sending with an unawaited
sendMessage before closing (#275). Left in place it would also hide the
same error from any other popup code that sends and then closes. The
entry goes, with the code that only printed and set aside tolerated
errors, and the README paragraph that described it.

Model: opus-5-5
2026-10-07 04:01:27 +00:00
53 changed files with 344 additions and 4815 deletions
+1 -20
View File
@@ -1,4 +1,4 @@
.PHONY: bootstrap setup install test test-e2e test-e2e-firefox lint fmt fmt-check check check-censored docker hooks build build-debug package vendor-blocklist icons enrich-tokens apply-enrichment clean dev .PHONY: bootstrap setup install test test-e2e test-e2e-firefox lint fmt fmt-check check check-censored docker hooks build build-debug package vendor-blocklist clean dev
# Standard targets are thin shims; the implementations live in script/ # Standard targets are thin shims; the implementations live in script/
# per the scripts-to-rule-them-all pattern (see the Entrypoints section # per the scripts-to-rule-them-all pattern (see the Entrypoints section
@@ -104,25 +104,6 @@ build-debug:
vendor-blocklist: vendor-blocklist:
@script/vendor-blocklist @script/vendor-blocklist
# Redraw the toolbar icons in icons/ from script/lib/icons.js, at every size
# manifest/chrome.json declares, leaving alone a file that already holds the
# drawn image. Commit the result with the drawing: tests/icons.test.js fails
# while the two disagree.
icons:
@node script/lib/icons.js
# Fetch a name and a website for every token in src/shared/tokenList.js into
# script/lib/token-enrichment.json. Run deliberately, land the diff: the names
# come from live data, so a later run can return different ones.
enrich-tokens:
@node script/lib/enrich-tokens.js
# Write the names and websites in script/lib/token-enrichment.json into
# src/shared/tokenList.js. Commit the result with the data file:
# tests/applyEnrichment.test.js fails while the two disagree.
apply-enrichment:
@node script/lib/apply-enrichment.js
clean: clean:
@rm -rf dist/ release/ @rm -rf dist/ release/
+46 -151
View File
@@ -239,24 +239,18 @@ provide:
`make check` stays non-mutating. Linting runs only in the container, because `make check` stays non-mutating. Linting runs only in the container, because
an ESLint result that depends on whichever ESLint the host happens to have is an ESLint result that depends on whichever ESLint the host happens to have is
not a result, so docker is required to lint. not a result, so docker is required to lint.
- `script/fmt` — format all files (writes), on the host, with the `prettier` - `script/fmt` — format all files (writes), on the host
that `script/bootstrap` installs in `node_modules/`; when that one is missing - `script/fmt-check` — check formatting (read-only), on the host
it fails and says to run `script/bootstrap`, instead of using a `prettier`
from the `PATH`
- `script/fmt-check` — check formatting (read-only), on the host, with the same
`prettier` and the same refusal
- `script/check` — run `script/test`, `script/lint` and `script/fmt-check` - `script/check` — run `script/test`, `script/lint` and `script/fmt-check`
- `script/check-censored` — assert the competitor name RULES.md bars appears - `script/check-censored` — assert the competitor name RULES.md bars appears
nowhere in the working tree or under `dist/` outside its documented nowhere in the working tree or under `dist/` outside its documented
exceptions: the pinned source reference in `script/vendor-blocklist`, the two exceptions: the pinned source reference in `script/vendor-blocklist`, the two
provider-shim identifiers in `src/content/inpage.js`, and one ERC-20's provider-shim identifiers in `src/content/inpage.js`, and one ERC-20's
on-chain name in `src/shared/tokenList.js` and in on-chain name in `src/shared/tokenList.js`. Each is scoped to that path and
`script/lib/token-enrichment.json`, the fetched data that file's names are fails anywhere else. Run by the `lint` phase, so part of `make check`; it
written from. Each is scoped to the paths named and fails anywhere else. Run inspects `dist/` when there is one and says loudly when there is not, and the
by the `lint` phase, so part of `make check`; it inspects `dist/` when there build context of that phase never has one. `make build` re-runs it with
is one and says loudly when there is not, and the build context of that phase `--require-dist`, so a build artifact is always covered
never has one. `make build` re-runs it with `--require-dist`, so a build
artifact is always covered
- `script/package` — produce the release artifacts: `make build` first, so the - `script/package` — produce the release artifacts: `make build` first, so the
archives can only ever be made from a `dist/` that has been verified against archives can only ever be made from a `dist/` that has been verified against
that build's own receipt as a RELEASE build, then one self-contained, that build's own receipt as a RELEASE build, then one self-contained,
@@ -323,24 +317,6 @@ The Makefile shims to those. It also carries a few targets that have no
- `make build-debug` — the same build with `AUTISTMASK_DEBUG=1`, verified as a - `make build-debug` — the same build with `AUTISTMASK_DEBUG=1`, verified as a
debug build, and keeping its `dist/` on failure (see debug build, and keeping its `dist/` on failure (see
[Debug Builds](#debug-builds)) [Debug Builds](#debug-builds))
- `make icons` — redraw the toolbar icons in `icons/` from
`script/lib/icons.js`, at every size `manifest/chrome.json` declares. A file
that already holds the drawn image, the same IHDR and every pixel, is left
untouched. Commit the files with the drawing: `make check` fails while their
image differs from what it draws
- `make enrich-tokens` — fetch the name of each token in
`src/shared/tokenList.js` from CoinGecko's coin list and its website from the
`ethereum-lists/tokens` repository, at a pinned commit, into
`script/lib/token-enrichment.json`, by running `script/lib/enrich-tokens.js`.
CoinGecko's list is live and has no fixed version, so a later run can return
different names
- `make apply-enrichment` — write the names and URLs in
`script/lib/token-enrichment.json` into `src/shared/tokenList.js`, by running
`script/lib/apply-enrichment.js`, taking a URL from the table of project
websites in that script only where the fetched data has none. Commit the list
with the data file: `make check` fails while the list differs from what this
writes. Neither target changes which tokens are on the list, and no build runs
either
- `make clean` — remove `dist/` and `release/` - `make clean` — remove `dist/` and `release/`
- `make dev` — run the build `make build` runs, without the checks that follow - `make dev` — run the build `make build` runs, without the checks that follow
it, then run it again after every change to a file under `src/`, `manifest/` it, then run it again after every change to a file under `src/`, `manifest/`
@@ -433,20 +409,17 @@ 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 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 handler on a reserved-TLD origin, gets `window.ethereum` from the shipped
`MAIN`-world content script like any other page, and drives `MAIN`-world content script like any other page, and drives
`eth_requestAccounts`, `personal_sign`, `eth_signTypedData_v4`, `eth_requestAccounts`, `personal_sign`, `eth_signTypedData_v4` and
`eth_sendTransaction` and `wallet_switchEthereumChain` through the real prompts. `eth_sendTransaction` through the real prompts. Every signature is recovered in
Every signature is recovered in the runner and compared against the active the runner and compared against the active address, the transaction assertions
address, the transaction assertions run against the raw signed transaction run against the raw signed transaction captured at `eth_sendRawTransaction`
captured at `eth_sendRawTransaction` rather than against anything the extension rather than against anything the extension reported, rejecting each prompt is
reported, rejecting each prompt is required to return a rejection to the page required to return a rejection to the page rather than hang or resolve, a prompt
rather than hang or resolve, a network switch request is required to leave the raised while another approval window has focus is required to open a window of
stored network unchanged and send no `chainChanged` until it is approved, a its own, and the password is required to be absent from every message the
network switch in Settings is required to send the page `chainChanged` with the approval window sends to the background — with the message that would carry it
new chain id, a prompt raised while another approval window has focus is required to be present, so that check cannot pass by observing nothing. That
required to open a window of its own, and the password is required to be absent last one is the standing floor under
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). [#157](https://git.eeqj.de/sneak/AutistMask/issues/157).
The limits of that coverage and of the rest of the Chrome suite, none of them The limits of that coverage and of the rest of the Chrome suite, none of them
@@ -547,11 +520,10 @@ Chrome that ever changes this fails the run instead of passing it.
`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, the StateRecovery screen (the same cases as the Chrome It covers popup load, the StateRecovery screen (the same cases as the Chrome
suite), wallet creation through the UI, the Add Token screen, and the dApp round suite), wallet creation through the UI, the Add Token screen, and the four dApp
trips — `eth_requestAccounts`, `personal_sign`, `wallet_switchEthereumChain` round trips — `eth_requestAccounts`, `personal_sign`, `eth_sendTransaction`, and
rejected and then approved, `eth_sendTransaction`, and a closed approval window a closed approval window rejecting with EIP-1193 4001 — driven through the real
rejecting with EIP-1193 4001 — driven through the real content script, content script, background page and approval windows.
background page and approval windows.
The suite lives in `tests/e2e/firefox/`. Its WebDriver client (`driver.js`) has 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 **no npm dependencies at all**: it is built on global `fetch` and
@@ -1286,21 +1258,14 @@ path rather than on the home screen. Read the claim narrowly, as that file
states it: what those boots prove is no structural dereference on the code paths states it: what those boots prove is no structural dereference on the code paths
a WHOLLY-CORRUPTED PROFILE takes, which is not every path a stored record takes. a WHOLLY-CORRUPTED PROFILE takes, which is not every path a stored record takes.
Not driven: any pairing of values the four slots do not produce, a view only Not driven: any pairing of values the four slots do not produce, a view only
forward navigation opens, anything behind a click or a timer, and, of what a forward navigation opens, anything behind a click, and everything a healthy
healthy profile reaches, anything beyond its boot onto each view the popup can profile reaches. Within that boundary the verdict is unconditional — if one of
reopen onto. The fields the router does not read share a slot on each boot, so those boots leaves the popup unhealthy or off the view it stored, `make check`
one of them truthy while another is falsy is reached only where the falsy slot fails, including when it takes two corrupted fields at once, because the verdict
pairs a field that cannot be falsy with one that is. Within that boundary the is the combined boot and the per-field re-boot that names a culprit can only
verdict is unconditional — if one of those boots leaves the popup unhealthy, decorate the message. So does a field that gains a floor while its row still
`make check` fails, including when it takes two corrupted fields at once, claims it has none, and so does a field added to `PERSISTED_FIELDS` with no row
because the verdict is the combined boot and the per-field re-boot that names a at all. The per-field justification that used to live in the header of
culprit can only decorate the message. The combined boot must also land on the
view it stored, and each value driven only onto the restore path must land on
its view, or fall back to Home, as its row declares; a field the router reads
can legitimately change which view renders, so its own sweep is held to health
alone. `make check` also fails on a field that gains a floor while its row still
claims it has none, and on a field added to `PERSISTED_FIELDS` with no row at
all. The per-field justification that used to live in the header of
`src/shared/stateSchema.js` shipped a false claim in three consecutive changes, `src/shared/stateSchema.js` shipped a false claim in three consecutive changes,
each caught only by a reviewer re-deriving thirty fields by hand. each caught only by a reviewer re-deriving thirty fields by hand.
@@ -1474,17 +1439,14 @@ view would leave a wallet one click from deletion.
without it, the lost-password route on DeleteWallet is the first they without it, the lost-password route on DeleteWallet is the first they
would hear of it. The hint line reserves its height, so switching tabs would hear of it. The hint line reserves its height, so switching tabs
cannot move the password fields under the pointer. cannot move the password fields under the pointer.
- "Import" button, with the error line beside it so that it adds no height - "Import" button
to a screen whose button already starts near the bottom of the popup
- **Transitions**: - **Transitions**:
- "Import" with a valid entry and a matching password of at least 12 - "Import" with a valid entry and a matching password of at least 12
characters → creates the wallet, clears the navigation stack, and → characters → creates the wallet, clears the navigation stack, and →
**Home**. The phrase and xprv modes then scan for further used addresses **Home**. The phrase and xprv modes then scan for further used addresses
and report the count as a flash message. and report the count as a flash message.
- "Import" with a missing, short or mismatched password → full-sentence - "Import" with an invalid entry, a duplicate wallet or address, or a short
error on the error line, no screen change or mismatched password → flash message, no screen change
- "Import" with an invalid entry or a duplicate wallet or address → flash
message, no screen change
- "Back" → previous screen (Welcome, Home, or Settings) - "Back" → previous screen (Welcome, Home, or Settings)
#### AddressDetail (`address`) #### AddressDetail (`address`)
@@ -1523,8 +1485,8 @@ view would leave a wallet one click from deletion.
copy) copy)
- Warning that anyone holding the private key can transfer all funds from - Warning that anyone holding the private key can transfer all funds from
the address the address
- Password input, error line and "Reveal" button, shown until the key is - Error line
revealed - Password input and "Reveal" button, shown until the key is revealed
- The private key on a highlighted background, tap to copy, shown only after - The private key on a highlighted background, tap to copy, shown only after
the password has been accepted the password has been accepted
- **Transitions**: - **Transitions**:
@@ -1543,14 +1505,6 @@ view would leave a wallet one click from deletion.
route, including the Settings gear. A decrypt still running when the screen is route, including the Settings gear. A decrypt still running when the screen is
left is discarded rather than written. The screen is not restorable, so left is discarded rather than written. The screen is not restorable, so
reopening the popup lands on Home rather than back on the key. reopening the popup lands on Home rather than back on the key.
- **Clipboard**: tapping the key copies it to the clipboard, and the wallet
never clears the clipboard afterwards; leaving the screen wipes the key from
the page only. The clipboard is the user's, not the wallet's. Clearing it
would go against what the user expects, and by then they may have copied
something else vital that the clear would destroy. The user knows the key is
secret from the warning above the password input, which says that anyone with
it can access and transfer all funds from the address, and knows it is on the
clipboard because they copied it. From then on it is theirs to manage.
#### AddressToken (`address-token`) #### AddressToken (`address-token`)
@@ -1805,12 +1759,8 @@ view would leave a wallet one click from deletion.
plus a "+ Add token" button plus a "+ Add token" button
- Display: "Show tracked tokens with zero balance" checkbox, "UTC - Display: "Show tracked tokens with zero balance" checkbox, "UTC
Timestamps" checkbox, and a Theme selector (System / Light / Dark) Timestamps" checkbox, and a Theme selector (System / Light / Dark)
- Network: network selector (Ethereum Mainnet / Sepolia Testnet). The - Network: network selector (Ethereum Mainnet / Sepolia Testnet); switching
network changes only here, or when the user approves a site's request on resets the RPC and Blockscout endpoints to that network's defaults
**NetworkApproval**; either way, switching restores the RPC and Blockscout
endpoints last used on that network, or that network's defaults if it has
none, and sends `chainChanged` with the new chain id to every open tab.
Choosing the network already active changes nothing and sends nothing
- Ethereum RPC: endpoint URL input + "Save" button (validated against - Ethereum RPC: endpoint URL input + "Save" button (validated against
`eth_chainId` before being saved) `eth_chainId` before being saved)
- Blockscout API: endpoint URL input + "Save" button (validated against - Blockscout API: endpoint URL input + "Save" button (validated against
@@ -1866,8 +1816,8 @@ view would leave a wallet one click from deletion.
- Wallet name - Wallet name
- Warning box stating that anyone holding these words can take everything in - Warning box stating that anyone holding these words can take everything in
the wallet, from any device, without the password the wallet, from any device, without the password
- Password input, error line and "Reveal" button, shown until the password - Error line
is accepted - Password input + "Reveal" button, shown until the password is accepted
- The recovery phrase itself, in full and click-to-copy, shown only after a - The recovery phrase itself, in full and click-to-copy, shown only after a
correct password and in place of the password prompt correct password and in place of the password prompt
- **Transitions**: - **Transitions**:
@@ -1885,9 +1835,6 @@ view would leave a wallet one click from deletion.
gear. A decrypt still running when the screen is left is discarded rather than gear. A decrypt still running when the screen is left is discarded rather than
written. The screen is not restorable, so reopening the popup lands on Home written. The screen is not restorable, so reopening the popup lands on Home
rather than back on the phrase. rather than back on the phrase.
- **Clipboard**: tapping the phrase copies it, and the wallet never clears the
clipboard afterwards, for the reasons given under ExportPrivKey; here the
warning box above the password input is what tells the user it is secret.
#### DeleteWallet (`delete-wallet-confirm`) #### DeleteWallet (`delete-wallet-confirm`)
@@ -1896,8 +1843,8 @@ view would leave a wallet one click from deletion.
- "Back" button, "Delete Wallet" heading - "Back" button, "Delete Wallet" heading
- Warning naming the wallet and stating that deletion is permanent and any - Warning naming the wallet and stating that deletion is permanent and any
funds are unrecoverable without the recovery phrase funds are unrecoverable without the recovery phrase
- Password input
- Error line - Error line
- Password input
- "Confirm Delete" button - "Confirm Delete" button
- An underlined "I have lost my password" control - An underlined "I have lost my password" control
- **Transitions**: - **Transitions**:
@@ -2090,9 +2037,7 @@ view would leave a wallet one click from deletion.
artifact can be compared with it field for field. A nonce the site supplies is artifact can be compared with it field for field. A nonce the site supplies is
ignored: the nonce is always the account's next nonce from the node, so a site ignored: the nonce is always the account's next nonce from the node, so a site
cannot replace one of the user's pending transactions or leave this one stuck cannot replace one of the user's pending transactions or leave this one stuck
behind a gap. A gas limit the site supplies, as `gas` or `gasLimit`, is used behind a gap. A request that cannot be populated — unreachable node, reverting
in place of the node's estimate; a request giving both with different values
is refused. A request that cannot be populated — unreachable node, reverting
gas estimate — opens no window and is failed back to the site. Only one gas estimate — opens no window and is failed back to the site. Only one
transaction approval exists at a time: populating fixes the nonce, so a second transaction approval exists at a time: populating fixes the nonce, so a second
`eth_sendTransaction` arriving while one is unanswered is refused with `eth_sendTransaction` arriving while one is unanswered is refused with
@@ -2100,10 +2045,7 @@ view would leave a wallet one click from deletion.
no window and takes no nonce, and the site can send it again once the pending no window and takes no nonce, and the site can send it again once the pending
one is answered. The window is centred on the browser window the user was last one is answered. The window is centred on the browser window the user was last
in; if that was another approval window, or the browser refuses the centred in; if that was another approval window, or the browser refuses the centred
position, the browser picks the position. The network shown is the one the position, the browser picks the position.
transaction was populated on, and a site cannot switch it without the user:
its `wallet_switchEthereumChain` request changes the network only when the
user approves it on **NetworkApproval**.
- **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
@@ -2181,13 +2123,6 @@ view would leave a wallet one click from deletion.
- Typed data that states no primary type, or one other than the type it - Typed data that states no primary type, or one other than the type it
would be signed as, or that cannot be read → shown with the error line would be signed as, or that cannot be read → shown with the error line
saying so and "Sign" disabled; only "Reject" remains saying so and "Sign" disabled; only "Reject" remains
- Typed data whose domain `chainId` names a chain other than the active
network's → shown with the error line naming both chain ids and "Sign"
disabled; only "Reject" remains. EIP-712 asks for this, because the
signature is valid on the chain the domain names. Typed data with no
`chainId` is not refused. The background refuses such a signature too,
against the network active when the signature reaches it, and answers the
site with the same sentence
- A `personal_sign` or `eth_sign` message that is not hex (`0x` or `0X` and - A `personal_sign` or `eth_sign` message that is not hex (`0x` or `0X` and
an even number of hex digits, the form ethers' `getBytes` reads when an even number of hex digits, the form ethers' `getBytes` reads when
signing) → shown as plain text, with the error line "This message is plain signing) → shown as plain text, with the error line "This message is plain
@@ -2201,40 +2136,6 @@ view would leave a wallet one click from deletion.
- Popup window closed without answering → the request is rejected with - Popup window closed without answering → the request is rejected with
EIP-1193 code 4001 EIP-1193 code 4001
#### NetworkApproval (`approve-network`)
- **When**: A connected website asks to switch the network with
`wallet_switchEthereumChain`, naming a supported network other than the active
one. Only the user switches the network: until the user approves the request
here, it changes nothing — not the network, the RPC and Blockscout endpoints,
the balances or the cached token data — and no page is sent `chainChanged`.
Opened the same way as TxApproval, in a separate popup window. Only one exists
per site at a time: a further switch request from a site whose switch request
is still unanswered is refused with EIP-1193 code `-32002` and opens no
window. A request naming the network already active is answered at once and
opens nothing; one naming an unsupported chain is refused with code `4902`,
and one from a site that is not connected with code `4100`.
`wallet_addEthereumChain` never switches the network.
- **Elements**:
- "Network Switch Request" heading
- Phishing warning banner (shown when the hostname is on the phishing
blocklist)
- Site origin (bold, scheme and port included) + "wants to switch the
wallet's network."
- Current network: its name
- Requested network: its name
- A line saying that switching changes the network for the whole wallet and
for every site
- "Switch" / "Reject" buttons
- **Transitions**:
- "Switch" → closes popup; the background switches the network as
**Settings** does, restoring the RPC and Blockscout endpoints last used on
that network (or that network's defaults if it has none), sends
`chainChanged` to every open tab, and answers the site with success
- "Reject" → closes popup; the site is answered with EIP-1193 code 4001 and
nothing changes
- Popup window closed without answering → the same as "Reject"
#### StateRecovery (`state-recovery`) #### StateRecovery (`state-recovery`)
- **When**: the stored profile fails `assertStateUsable()`. At open, that is - **When**: the stored profile fails `assertStateUsable()`. At open, that is
@@ -2529,8 +2430,6 @@ logged.
- Sign transactions requested by connected sites (`eth_sendTransaction`) - Sign transactions requested by connected sites (`eth_sendTransaction`)
- Sign messages (`personal_sign`, `eth_sign`) - Sign messages (`personal_sign`, `eth_sign`)
- Sign typed data (`eth_signTypedData_v4`, `eth_signTypedData`) - Sign typed data (`eth_signTypedData_v4`, `eth_signTypedData`)
- Switch network at a connected site's request, once the user approves it
(`wallet_switchEthereumChain`)
- Human-readable transaction decoding (ERC-20, Uniswap Universal Router) - Human-readable transaction decoding (ERC-20, Uniswap Universal Router)
- ETH/USD and token/USD price display - ETH/USD and token/USD price display
- Configurable RPC endpoint and Blockscout API - Configurable RPC endpoint and Blockscout API
@@ -2680,8 +2579,7 @@ plaintext list of anyone's domain names.
When a dApp on a blocklisted domain requests a wallet connection, transaction When a dApp on a blocklisted domain requests a wallet connection, transaction
approval, or signature, the approval popup displays a prominent red warning approval, or signature, the approval popup displays a prominent red warning
banner alerting the user. The domain checker matches exact hostnames and all banner alerting the user. The domain checker matches exact hostnames and all
parent domains (subdomain matching), and checks a hostname written with a parent domains (subdomain matching).
trailing dot (`name.`) as `name`.
#### Transaction Decoding #### Transaction Decoding
@@ -2724,7 +2622,7 @@ Currently supported:
### Non-Goals for 1.0 ### Non-Goals for 1.0
- Chains other than Ethereum mainnet and the Sepolia testnet - Multi-chain support (Ethereum mainnet only)
- Hardware wallet support - Hardware wallet support
## TODO ## TODO
@@ -2758,10 +2656,7 @@ Currently supported:
## Policies ## Policies
- We don't mention "the other wallet" by name in code or documentation. We're - We don't mention "the other wallet" by name in code or documentation. We're
our own thing. Written exception: the injected provider in our own thing.
`src/content/inpage.js` sets the flag named after the other wallet to `true`.
It is an interface-compatibility flag many dApps check, and without it the
wallet stops working on their sites.
- The README is the complete authoritative technical documentation. It's ok if - The README is the complete authoritative technical documentation. It's ok if
it gets big. it gets big.
@@ -2791,7 +2686,7 @@ exact URL, the commit it is pinned to and the sha256 of the bytes that commit
serves, because a source reference nobody can verify is not a source reference. serves, because a source reference nobody can verify is not a source reference.
`script/check-censored` reads the name back out of that one file and fails the `script/check-censored` reads the name back out of that one file and fails the
build wherever else it appears, save for three shipped-code literals it cannot build wherever else it appears, save for three shipped-code literals it cannot
avoid — each permitted only at the paths that carry it, and listed in that avoid — each permitted only at the one path that carries it, and listed in that
script's header. script's header.
## Author ## Author
+1 -4
View File
@@ -118,7 +118,4 @@ contradicts either, the originals govern.
- [ ] "Address" not "account" or "derived key" - [ ] "Address" not "account" or "derived key"
- [ ] "Password" not "encryption key" or "vault passphrase" - [ ] "Password" not "encryption key" or "vault passphrase"
- [ ] Error messages are full sentences - [ ] Error messages are full sentences
- [ ] No competitor mentioned by name in code or documentation. Written - [ ] No competitor mentioned by name in code or documentation
exception: the injected provider in `src/content/inpage.js` sets the flag
named after the other wallet to `true`, an interface-compatibility flag
many dApps check (README.md, Policies)
+18 -137
View File
@@ -2,9 +2,10 @@
- `git pull` `next` and cut a branch from it — one branch per issue, named - `git pull` `next` and cut a branch from it — one branch per issue, named
`issue-<N>-<slug>`. Never branch from `main`. `issue-<N>-<slug>`. Never branch from `main`.
- Do the work as one commit whose title ends with ` (closes #N)`. - Do the work as one commit whose title ends with ` (closes #N)`, with the
- Add no entry to Completed Steps. What landed is recorded on the tracker and in `TODO.md` update in that same commit.
`git log`, where each commit title names the issue it closes. - Move Next Step to the top of Completed Steps; move the top item of Future
Steps into Next Step.
- Run `make fmt`, then `make check`. A feature branch may be red; `next` and - Run `make fmt`, then `make check`. A feature branch may be red; `next` and
`main` may not. `main` may not.
- Rebase onto current `next` immediately before pushing — other branches land on - Rebase onto current `next` immediately before pushing — other branches land on
@@ -22,141 +23,28 @@
pre-1.0, working towards the 1.0.0 milestone. Tagged v0.1.0 on 2026-02-27. The pre-1.0, working towards the 1.0.0 milestone. Tagged v0.1.0 on 2026-02-27. The
milestone is in flight on `next`; its `next` -> `main` PR is milestone is in flight on `next`; its `next` -> `main` PR is
[#388](https://git.eeqj.de/sneak/AutistMask/pulls/388). `make build` produces [#190](https://git.eeqj.de/sneak/AutistMask/pulls/190). `make check` verified
`dist/chrome/` and `dist/firefox/` with `DEBUG` compiled off, and checks them green on `next` at `e9fa8be` on 2026-08-10, and `make build` produces
against the build's own receipt to hold exactly the regular files and symlinks `dist/chrome/` and `dist/firefox/`, verified against the build's own receipt to
that build emitted. hold exactly the regular files and symlinks that build emitted, with `DEBUG`
compiled off.
The backlog lives on the The backlog lives on the
[Gitea tracker](https://git.eeqj.de/sneak/AutistMask/issues), which is [Gitea tracker](https://git.eeqj.de/sneak/AutistMask/issues), which is
authoritative; this file does not duplicate it. Full policy file set present. authoritative; this file does not duplicate it. Full policy file set present.
Real-browser end-to-end suites (`make test-e2e` for Chrome, Real-browser end-to-end suites (`make test-e2e` for Chrome,
`make test-e2e-firefox` for Firefox) sit alongside `make check`, which runs the `make test-e2e-firefox` for Firefox) sit alongside `make check`, which now does
tests, static analysis and the formatting check, and `.gitea/workflows/e2e.yml` static analysis as well as formatting, and `.gitea/workflows/e2e.yml` runs both
runs both of them on every push. of them on every push.
# Next Step # Next Step
Cut 1.0.0 once the Pre-1.0 security review of the extension (key handling, DEBUG mode policy, RPC
[1.0.0 milestone](https://git.eeqj.de/sneak/AutistMask/milestone/6) is empty, input validation) before any 1.0rc tag. Individual filed issues are parts of it,
then continue tagging as milestones land. but the review is broader than any of them.
# Completed Steps # Completed Steps
- 2026-10-08: The browser suites no longer read a screen before the page has
shown it ([#502](https://git.eeqj.de/sneak/AutistMask/issues/502)). Their wait
for a screen used to pass as soon as the element laid out, which every view
does until the page's stylesheet has applied, so an approval test could read
the prompt's fields before the page's script had filled them. `visible()` in
`tests/e2e/harness.js` and `waitVisible()` in `tests/e2e/firefox/driver.js`
now also wait for the page to finish loading and for neither the element nor
anything around it to carry the `hidden` class that `showView()` puts on every
view but the current one. No caller changed.
- 2026-10-08: Switching the network in Settings now tells open pages
([#500](https://git.eeqj.de/sneak/AutistMask/issues/500)). Once the switch is
saved, Settings asks the background to send `chainChanged` with the new chain
id to every open tab, through the same function an approved site request uses.
Choosing the network already active changes nothing and sends nothing. Only
the extension's own pages can ask for this. `tests/chainSwitchGate.test.js`
drives the real Settings view against the background, and the Chrome suite
checks that the test page hears both switches.
- 2026-10-07: A site can no longer switch the wallet's network by itself
([#408](https://git.eeqj.de/sneak/AutistMask/issues/408)). A connected site's
`wallet_switchEthereumChain` request for the other supported network opens a
prompt in its own window, through the same approval machinery as the
transaction and signature prompts, naming the site, the current network and
the requested one. The network, its endpoints, the balances and the caches
change, and `chainChanged` is sent, only when the user approves it; rejecting
or closing the prompt answers 4001. One such prompt per site at a time. The
approval window no longer shows the connection prompt while it waits for the
background to describe the approval, because that prompt's "Allow" answers on
the same port as the new one; `tests/approvalWindow.test.js` checks that it
shows no screen until then. `tests/chainSwitchGate.test.js` and both browser
suites drive the prompt.
- 2026-10-07: Two contradictions between the documents and the code are resolved
as ruled on [#165](https://git.eeqj.de/sneak/AutistMask/issues/165). The
README's 1.0 non-goal now puts Ethereum mainnet and the Sepolia testnet in
scope and other chains out of it. The injected provider's flag named after the
other wallet stays, as an interface-compatibility flag many dApps check, and
is written down as an exception to the rule against naming competitors in the
README's Policies section, in `RULES.md` and in a comment beside it in
`src/content/inpage.js`. `script/check-censored` already allows that flag only
in that file and its built copies, so it is unchanged.
- 2026-10-07: Two holes in what `tests/persistedFieldContract.test.js` checks
are closed ([#379](https://git.eeqj.de/sneak/AutistMask/issues/379)). The
check that every swept field is driven both truthy and falsy counts only
`hostile` and `falsy` values, which the sweep drives onto every restorable
view, and no longer a `hostileRestore` value, which reaches only the views its
entry names; the stale index 5 moves into `hostile` for `selectedWallet` and
`selectedAddress`, as the one value of either still truthy after the floor.
Each `hostileRestore` entry declares whether its boot lands on its view or
falls back to Home, and is held to it. The limits that remain, fields that
share a slot on one boot and anything no stored record reaches by itself, are
restated as built in the file's header and the README, which now also say
which boots are held to where the popup lands and that a healthy profile is
booted onto every restorable view.
- 2026-10-07: Every password error in the popup is shown the same way
([#493](https://git.eeqj.de/sneak/AutistMask/issues/493)): with `showError()`
and `hideError()` in a fixed-height error line below the password field, as
the send confirmation and approval screens already did. The add wallet screen
showed a missing, short or mismatched password in the flash line, and the
private key export, recovery phrase and delete wallet screens each had a line
of their own above the field. On the add wallet screen the line sits beside
the Import button, so the button stays where it was at 360x600. Each line
clears when the screen is shown again and when the password is tried again.
The add wallet screen's other messages, such as an invalid recovery phrase, a
duplicate wallet and the address scan, stay in the flash line.
`tests/passwordErrorLines.test.js` drives all four screens in the popup.
- 2026-10-07: Pre-1.0 security review of the extension
([#383](https://git.eeqj.de/sneak/AutistMask/issues/383)), reading the tree at
`99292b9` for key handling, the DEBUG mode policy, and what the background
accepts from pages, the configured RPC endpoint and the explorer, with what
the approval screens show from it; the site permission model and storage were
read as well. Its summary on that issue lists ten findings, each filed as its
own issue, and all ten are fixed on `next`; one,
[#399](https://git.eeqj.de/sneak/AutistMask/issues/399), put funds at risk.
Three decisions it raised are still open with the owner: the Argon2id cost for
the vault key ([#401](https://git.eeqj.de/sneak/AutistMask/issues/401)), a
connected site switching the network with no prompt
([#408](https://git.eeqj.de/sneak/AutistMask/issues/408)), and `eth_sign`
signing as a personal message
([#409](https://git.eeqj.de/sneak/AutistMask/issues/409)). Not covered: the
end-to-end suites were not run, the bundled phishing blocklist and token list
were not checked entry by entry, `ethers` and `libsodium-wrappers-sumo` were
taken as audited, and nothing was tried against a real network with real funds
([#385](https://git.eeqj.de/sneak/AutistMask/issues/385)). The planned
independent second check of each finding did not run; the findings rest on the
reviewer's own reading of the code.
- 2026-10-07: Stale branches pruned from `origin`
([#167](https://git.eeqj.de/sneak/AutistMask/issues/167)). The issue
classifies each branch it lists, with the evidence. The eighteen still on
`origin` that it classifies as landed, or superseded by merged pull requests,
were deleted. `feat/message-signing` and `fix/59-transaction-view-ui-policies`
had been deleted on 2026-09-09; both landed and stay deleted. Four are kept
because their work is not in `next`: `fix/consistent-error-display`,
`fix/87-consistent-error-display` and `fix/87-consistent-error-display-v2`,
the change for [#87](https://git.eeqj.de/sneak/AutistMask/issues/87) that
never landed, as reference for
[#493](https://git.eeqj.de/sneak/AutistMask/issues/493); and
`chore/token-list-enrichment`, deleted on 2026-09-09 and re-created at
`f7a2437`, whose `scripts/` tooling waits on
[#495](https://git.eeqj.de/sneak/AutistMask/issues/495).
- 2026-10-07: The README says that the wallet never clears the clipboard after
the private key or the recovery phrase is copied, and why
([#492](https://git.eeqj.de/sneak/AutistMask/issues/492)): the clipboard is
the user's, clearing it would go against what they expect, and it could
destroy something else they copied since. It is under ExportPrivKey, with a
line under ShowRecoveryPhrase, and names the warning each password screen
actually shows, which says nothing about the clipboard.
- 2026-10-07: The Firefox end-to-end suite no longer tolerates any uncaught - 2026-10-07: The Firefox end-to-end suite no longer tolerates any uncaught
extension error ([#487](https://git.eeqj.de/sneak/AutistMask/issues/487)). Its extension error ([#487](https://git.eeqj.de/sneak/AutistMask/issues/487)). Its
one entry, Firefox reporting a popup promise that settled after the page one entry, Firefox reporting a popup promise that settled after the page
@@ -165,16 +53,6 @@ then continue tagging as milestones land.
code that printed tolerated errors are gone from `tests/e2e/firefox/run.js`, code that printed tolerated errors are gone from `tests/e2e/firefox/run.js`,
and so is the README paragraph that described it. and so is the README paragraph that described it.
- 2026-10-07: The toolbar icons in `icons/` can be redrawn in the tree
([#378](https://git.eeqj.de/sneak/AutistMask/issues/378)): `make icons` runs
`script/lib/icons.js`, which draws the mark and writes each PNG with node's
own `zlib`, no new dependency, leaving alone a file that already holds the
drawn image. `tests/icons.test.js` requires each committed file to hold
exactly that image, the same IHDR and every pixel. The compressed bytes are
not compared, because node's bundled zlib does not compress them as the stock
zlib that made the committed files did. `build.js` now copies each manifest
from the same path `copyIcons()` reads its icons from.
- 2026-10-07: The README's end-to-end limits now match what the two browser - 2026-10-07: The README's end-to-end limits now match what the two browser
suites do to the extension suites do to the extension
([#293](https://git.eeqj.de/sneak/AutistMask/issues/293)). The `window.close` ([#293](https://git.eeqj.de/sneak/AutistMask/issues/293)). The `window.close`
@@ -1976,3 +1854,6 @@ then continue tagging as milestones land.
Only work that has no issue of its own belongs here; everything else is on the Only work that has no issue of its own belongs here; everything else is on the
tracker. tracker.
- Cut 1.0.0 once the milestone is empty, then continue tagging as milestones
land.
+9 -4
View File
@@ -588,10 +588,15 @@ async function build() {
copyIcons(distDir); copyIcons(distDir);
} }
// copy manifests, the same files copyIcons() read // copy manifests
for (const [distDir, manifestPath] of MANIFEST_SOURCES) { copyEmitted(
copyEmitted(manifestPath, path.join(distDir, "manifest.json")); path.join(__dirname, "manifest", "chrome.json"),
} path.join(DIST_CHROME, "manifest.json"),
);
copyEmitted(
path.join(__dirname, "manifest", "firefox.json"),
path.join(DIST_FIREFOX, "manifest.json"),
);
assertForbiddenTableCovered(forbiddenRecord); assertForbiddenTableCovered(forbiddenRecord);
+2 -4
View File
@@ -18,8 +18,6 @@
# (src/shared/balances.js), and this contract's on-chain name is that # (src/shared/balances.js), and this contract's on-chain name is that
# string, so censoring the repo cannot stop the wallet displaying it. # string, so censoring the repo cannot stop the wallet displaying it.
# Dropping the entry instead would cost the user MUSD spoof detection. # Dropping the entry instead would cost the user MUSD spoof detection.
# script/lib/token-enrichment.json, the fetched data tokenList.js's names
# are written from, carries the same name for the same reason.
# #
# Everything else fails, in the working tree and under dist/. The last two are # Everything else fails, in the working tree and under dist/. The last two are
# literals rather than whole files, so they are enforced by counting, and each # literals rather than whole files, so they are enforced by counting, and each
@@ -104,8 +102,8 @@ allowed_literals_for() {
src/content/inpage.js | dist/*/src/content/inpage.js) src/content/inpage.js | dist/*/src/content/inpage.js)
printf 'is%s\n_%s\n' "$NAME" "$NAME" >"$ALLOWED_LITERALS_FILE" printf 'is%s\n_%s\n' "$NAME" "$NAME" >"$ALLOWED_LITERALS_FILE"
;; ;;
src/shared/tokenList.js | script/lib/token-enrichment.json | \ src/shared/tokenList.js | dist/*/src/background/index.js | \
dist/*/src/background/index.js | dist/*/src/popup/index.js) dist/*/src/popup/index.js)
printf '%s USD\n' "$NAME" >"$ALLOWED_LITERALS_FILE" printf '%s USD\n' "$NAME" >"$ALLOWED_LITERALS_FILE"
;; ;;
esac esac
-7
View File
@@ -25,13 +25,6 @@ run_yarn() {
main() { main() {
cd "$ROOT" cd "$ROOT"
# Without the repo's own prettier, yarn runs whatever prettier is on
# the PATH, which may be a version that formats files differently.
if [ ! -x node_modules/.bin/prettier ]; then
echo "fmt: this repo's prettier is not installed in node_modules/," \
"so run script/bootstrap first." >&2
exit 1
fi
echo "Formatting..." echo "Formatting..."
run_yarn run fmt run_yarn run fmt
} }
-7
View File
@@ -26,13 +26,6 @@ run_yarn() {
main() { main() {
cd "$ROOT" cd "$ROOT"
# Without the repo's own prettier, yarn runs whatever prettier is on
# the PATH, which may be a version that formats files differently.
if [ ! -x node_modules/.bin/prettier ]; then
echo "fmt-check: this repo's prettier is not installed in" \
"node_modules/, so run script/bootstrap first." >&2
exit 1
fi
echo "Checking formatting..." echo "Checking formatting..."
run_yarn run fmt-check run_yarn run fmt-check
} }
-180
View File
@@ -1,180 +0,0 @@
// Writes the names and URLs in script/lib/token-enrichment.json, which
// script/lib/enrich-tokens.js fetches, into the matching entries of
// src/shared/tokenList.js: the name and url an entry already has are replaced,
// and an entry without them gets them after its decimals. Where the fetched
// data has no URL for a token, EXTRA_URLS below supplies one if it lists the
// token's address spelled exactly as tokenList.js spells it, capitals included.
// An entry the data does not mention is left as it is. Run by
// `make apply-enrichment`; no build runs it. tests/applyEnrichment.test.js
// checks that the committed tokenList.js is exactly what this writes from the
// committed data.
"use strict";
const fs = require("fs");
const path = require("path");
const TOKEN_LIST = path.join(__dirname, "..", "..", "src/shared/tokenList.js");
// Project websites by token address. A token gets its URL here only where the
// fetched data has none; an address the data has a URL for, or that is not on
// the token list, is never used. An address matches only as tokenList.js spells
// it, capitals included: the FLOKI, TURBO and MANA entries use other capitals
// and never match, so FLOKI and TURBO have no URL.
const EXTRA_URLS = {
"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2": "https://weth.io",
"0xdAC17F958D2ee523a2206206994597C13D831ec7": "https://tether.to",
"0xB8c77482e45F1F44dE1745F52C74426C631bDD52": "https://www.bnbchain.org",
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48": "https://www.circle.com/usdc",
"0xdC035D45d973E3EC169d2276DDab16f1e407384F": "https://sky.money",
"0x2AF5D2aD76741191D15Dfe7bF6aC92d4Bd912Ca3": "https://www.bitfinex.com",
"0x514910771AF9Ca656af840dff83E8264EcF986CA": "https://chain.link",
"0x4c9EDD5852cd905f086C759E8383e09bff1E68B3": "https://ethena.fi",
"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d":
"https://www.worldlibertyfinancial.com",
"0x6B175474E89094C44Da98b954EedeAC495271d0F": "https://makerdao.com",
"0x6c3ea9036406852006290770BEdFcAbA0e23A0e8":
"https://www.paypal.com/us/digital-wallet/manage-money/crypto/pyusd",
"0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE": "https://shibatoken.com",
"0xdA5e1988097297dCdc1f90D4dFE7909e847CBeF6":
"https://www.worldlibertyfinancial.com",
"0xA0b73E1Ff0B80914AB6fe0444E65848C4C34450b": "https://crypto.com",
"0x582d872A1B094FC48F5DE31D3B73F2D9bE47def1": "https://ton.org",
"0x68749665FF8D2d112Fa859AA293F07A622782F38": "https://gold.tether.to",
"0x196C20DA81Fbc324EcdF55501e95Ce9f0bD84d14": "https://polkadot.network",
"0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984": "https://uniswap.org",
"0x45804880De22913dAFE09f4980848ECE6EcbAf78":
"https://www.paxos.com/paxgold",
"0x3c3a81e81dc49A522A592e7622A7E711c06bf354": "https://www.mantle.xyz",
"0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9": "https://aave.com",
"0x75231F58b43240C9718Dd58B4967c5114342a86c": "https://www.okx.com",
"0x6982508145454Ce325dDbE47a25d4ec3d2311933": "https://www.pepe.vip",
"0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599": "https://wbtc.network",
"0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84": "https://lido.fi",
"0x5A98FcBEA516Cf06857215779Fd812CA3beF1B32": "https://lido.fi",
"0xC18360217D8F7Ab5e7c516566761Ea12Ce7F9D72": "https://ens.domains",
"0x7D1AfA7B718fb893dB30A3aBc0Cfc608AaCfeBB0": "https://polygon.technology",
"0x9f8F72aA9304c8B593d555F12eF6589cC3A579A2": "https://makerdao.com",
"0xD533a949740bb3306d119CC777fa900bA034cd52": "https://curve.fi",
"0x111111111117dC0aa78b770fA6A738034120C302": "https://1inch.io",
"0x163f8C2467924be0ae7B5347228CABF260318753": "https://worldcoin.org",
"0xba100000625a3754423978a60c9317c58a424e3D": "https://balancer.fi",
"0xC011a73ee8576Fb46F5E1c5751cA3B9Fe0af2a6F": "https://synthetix.io",
"0x1a7e4e63778B4f12a199C062f3eFdD288afCBce8": "https://www.euler.finance",
"0xBBbbCA6A901c926F240b89EacB641d8Aec7AEafD": "https://loopring.org",
"0xC669928185DbCE49d2230CC9B0979BE6DC797957": "https://bitstable.finance",
"0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0": "https://lido.fi",
"0xBe9895146f7AF43049ca1c1AE358B0541Ea49704":
"https://www.coinbase.com/cbeth",
"0xfAbA6f8e4a5E8Ab82F62fe7C39859FA577269BE3": "https://ondo.finance",
"0x0C10bF8FcB7Bf5412187A595ab97a3609160b5c6": "https://decentraland.org",
"0x6De037ef9aD2725EB40118Bb1702EBb27e4Aeb24": "https://www.render.com",
"0x4a220E6096B25EADb88358cb44068A3248254675": "https://www.quant.network",
"0xba5BDe662c17e2aDFF1075610382B9B691296350": "https://superrare.com",
"0x15D4c048F83bd7e37d49eA4C83a07267Ec4203dA": "https://galxe.com",
"0xc00e94Cb662C3520282E6f5717214004A7f26888": "https://compound.finance",
"0xE41d2489571d322189246DaFA5ebDe1F4699F498": "https://0x.org",
"0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e": "https://yearn.fi",
"0x6fB3e0A217407EFFf7Ca062D46c26E5d60a14d69": "https://iotex.io",
"0xD31a59c85aE9D8edEFec411D448f90841571b89c": "https://solana.com",
"0xa1faa113cbE53436Df28FF0aEe54275c13B40975": "https://alphafinance.io",
"0xF57e7e7C23978C3cAEC3C3548E3D615c346e79fF": "https://immutable.com",
"0x0f5D2fB29fb7d3CFeE444a200298f468908cC942": "https://decentraland.org",
"0x3845badAde8e6dFF049820680d1F14bD3903a5d0": "https://www.sandbox.game",
"0x4d224452801ACEd8B2F0aebE155379bb5D594381": "https://apecoin.com",
"0x6810e776880C02933D47DB1b9fc05908e5386b96": "https://gnosis.io",
"0x5f98805A4E8be255a32880FDeC7F6728C6568bA0": "https://www.liquity.org",
"0xd26114cd6EE289AccF82350c8d8487fedB8A0C07": "https://omg.network",
"0x0D8775F648430679A709E98d2b0Cb6250d2887EF":
"https://basicattentiontoken.org",
"0xc944E90C64B2c07662A292be6244BDf05Cda44a7": "https://thegraph.com",
"0x1985365e9f78359a9B6AD760e32412f4a445E862": "https://augur.net",
"0x4E15361FD6b4BB609Fa63C81A2be19d873717870": "https://fantom.foundation",
"0x853d955aCEf822Db058eb8505911ED77F175b99e": "https://frax.finance",
"0x0000000000085d4780B73119b644AE5ecd22b376": "https://www.trueusd.com",
"0x0cEC1A9154Ff802e7934Fc916Ed7Ca50bDE6844e": "https://polymesh.network",
"0x956F47F50A910163D8BF957Cf5846D573E7f87CA": "https://fei.money",
"0x3432B6A60D23Ca0dFCa7761B7ab56459D9C964D0": "https://frax.finance",
"0xf939E0A03FB07F59A73314E73794Be0E57ac1b4E": "https://curve.fi",
"0x0100546F2cD4C9D97f798fFC9755E47865FF7Ee6": "https://alchemix.fi",
"0x808507121B80c02388fAd14726482e061B8da827": "https://pendle.finance",
"0xCa14007Eff0dB1f8135f4C25B34De49AB0d42766": "https://starknet.io",
"0x8457CA5040ad67fdebbCC8EdCE889A335Bc0fbFB": "https://alt.layer",
"0x4C19596f5aAfF459fA38B0f7eD92F11AE6543784": "https://www.truflation.com",
"0xB50721BCf8d664c30412Cfbc6cf7a15145234ad1": "https://arbitrum.io",
"0x912CE59144191C1204E64559FE8253a0e49E6548": "https://arbitrum.io",
"0x6985884C4392D348587B19cb9eAAf157F13271cd": "https://layerzero.network",
"0xFe0c30065B384F05761f15d0CC899D4F9F9Cc0eB": "https://ethfi.org",
"0x18084fbA666a33d37592fA2633fD49a74DD93a88": "https://tbtc.network",
"0x9D39A5DE30e57443BfF2A8307A4256c8797A3497": "https://suilend.fi",
"0x5283D291DBCF85356A21bA090E6db59121208b44": "https://blur.io",
"0x43Dfc4159D86F3A37A5A4B3D4580b888ad7d4DDd": "https://dydx.exchange",
"0xCf0C122c6b73ff809C693DB761e7BaeBe62b6a2E": "https://floki.com",
"0xe53EC727dbDEB9E2d5456c3be40cFF031AB40A55": "https://superfarm.com",
"0x32353A6C91143bfd6C7d363B546e62a9A2489A20": "https://www.agora.finance",
"0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c": "https://www.euro-coin.com",
"0x1a03eF3d1a80b5f214e468ACD97F55714E049393": "https://gemspad.io",
"0x7122985656e38BDC0302Db86685bb972b145bD3C": "https://stone.fi",
"0xa35923162C49cF95e6BF26623385eb431ad920D3": "https://www.turbo-eth.com",
"0xdBdb4d16EdA451D0503b854CF79D55697F90c8DF": "https://alchemix.fi",
"0x08c32b0726C5684024ea6e141C50aDe9690bBdcc": "https://stratis.io",
"0x65Ef703f5594D2573eb71Aaf55BC0CB548492df4": "https://multichain.org",
"0x1151CB3d861920e07a38e03eEAd12C32178567F6": "https://bonk.com",
"0x090185f2135308BaD17527004364eBcC2D37e5F6": "https://spell.club",
"0xAf5191B0De278C7286d6C7CC6ab6BB8A73bA2Cd6": "https://stargate.finance",
};
// Returns tokenList, the text of tokenList.js, with the name and url of each
// token in enrichment, the parsed data file, written into its entry.
function applyEnrichment(tokenList, enrichment) {
let result = tokenList;
for (const [addr, data] of Object.entries(enrichment)) {
// Escape special regex characters in the address
const escaped = addr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// Match the token block: { address: "...", symbol: "...", decimals: N, }
// and the name: "...", url: "..." after decimals when it has them.
// The closing }, may have trailing whitespace
const blockRe = new RegExp(
`(\\{\\s*address:\\s*"${escaped}",\\s*symbol:\\s*"[^"]+",\\s*decimals:\\s*\\d+)` +
`(?:,\\s*name:\\s*"(?:[^"\\\\]|\\\\.)*",\\s*url:\\s*"(?:[^"\\\\]|\\\\.)*")?` +
`,?(\\s*\\})`,
);
const match = result.match(blockRe);
if (!match) {
console.error(`WARNING: Could not find block for ${addr}`);
continue;
}
// A URL from EXTRA_URLS only where the fetched data has none.
const url = data.url || EXTRA_URLS[addr] || "";
// Build replacement: original fields + name + url
const nameEscaped = data.name
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"');
const urlEscaped = url.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const replacement =
match[1] +
`,\n name: "${nameEscaped}",` +
`\n url: "${urlEscaped}",` +
match[2];
// A function, so a "$" in a name is not read as a replacement pattern.
result = result.replace(blockRe, () => replacement);
}
return result;
}
if (require.main === module) {
const enrichment = JSON.parse(
fs.readFileSync(path.join(__dirname, "token-enrichment.json"), "utf8"),
);
const original = fs.readFileSync(TOKEN_LIST, "utf8");
fs.writeFileSync(TOKEN_LIST, applyEnrichment(original, enrichment));
}
module.exports = { applyEnrichment };
-115
View File
@@ -1,115 +0,0 @@
// Fetches a name and a website for every token in src/shared/tokenList.js and
// writes them, keyed by address, to script/lib/token-enrichment.json, which
// script/lib/apply-enrichment.js then writes into tokenList.js. Run by
// `make enrich-tokens`; no build runs it and nothing here is shipped.
//
// Names come from CoinGecko's coin list, fetched whole in one request, matched
// on the token's Ethereum contract address. A token CoinGecko does not list
// gets its symbol as its name. The list is live data with no fixed version, so
// a later run can return different names.
//
// Websites come from the ethereum-lists/tokens repository, one file per token,
// read at a pinned commit. Most tokens have no file there and get an empty URL.
// A file that is missing is not an error; any other failed request stops the
// run, so a network fault cannot quietly empty a URL.
"use strict";
const fs = require("fs");
const path = require("path");
const TOKEN_LIST = path.join(__dirname, "..", "..", "src/shared/tokenList.js");
const OUTPUT = path.join(__dirname, "token-enrichment.json");
const COINGECKO_LIST =
"https://api.coingecko.com/api/v3/coins/list?include_platform=true";
// ethereum-lists/tokens at its master of 2026-02-27, when the committed
// token-enrichment.json was made.
const ETHEREUM_LISTS =
"https://raw.githubusercontent.com/ethereum-lists/tokens/c3560d499a380a38e94caa7c4968d59aeffde59a/tokens/eth";
const CONCURRENT = 10;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function main() {
const src = fs.readFileSync(TOKEN_LIST, "utf8");
const tokens = [];
const re =
/\{\s*address:\s*"(0x[a-fA-F0-9]{40})",\s*symbol:\s*"([^"]+)",\s*decimals:\s*(\d+)/g;
let m;
while ((m = re.exec(src)) !== null) {
tokens.push({ address: m[1], symbol: m[2], decimals: parseInt(m[3]) });
}
console.error(`Parsed ${tokens.length} tokens`);
// --- Names from CoinGecko ---
console.error("Fetching CoinGecko coins list...");
const listResp = await fetch(COINGECKO_LIST);
if (!listResp.ok) throw new Error(`CoinGecko list HTTP ${listResp.status}`);
const coins = await listResp.json();
const addrMap = new Map();
for (const coin of coins) {
const eth = (coin.platforms?.ethereum || "").toLowerCase().trim();
if (eth && eth.startsWith("0x")) {
addrMap.set(eth, { name: coin.name, id: coin.id });
}
}
console.error(`CoinGecko map: ${addrMap.size} Ethereum tokens`);
const enriched = new Map();
let namesMatched = 0;
for (const t of tokens) {
const info = addrMap.get(t.address.toLowerCase());
if (info) namesMatched++;
enriched.set(t.address, { name: info?.name || t.symbol, url: "" });
}
console.error(`Matched ${namesMatched}/${tokens.length} names`);
// --- Websites from ethereum-lists ---
console.error("Fetching URLs from ethereum-lists GitHub...");
let urlsFound = 0;
for (let i = 0; i < tokens.length; i += CONCURRENT) {
const batch = tokens.slice(i, i + CONCURRENT);
await Promise.all(
batch.map(async (t) => {
const r = await fetch(`${ETHEREUM_LISTS}/${t.address}.json`);
if (r.status === 404) return;
if (!r.ok) {
throw new Error(
`ethereum-lists HTTP ${r.status} for ${t.address}`,
);
}
const data = await r.json();
const website = (data.website || "").trim();
if (website && website !== "https://") {
enriched.get(t.address).url = website;
urlsFound++;
}
}),
);
if (i % 100 === 0) {
console.error(
` GitHub: ${Math.min(i + CONCURRENT, tokens.length)}/${tokens.length}, ${urlsFound} URLs`,
);
}
await sleep(200);
}
console.error(`Found ${urlsFound} URLs in ethereum-lists`);
// --- Output, in the layout prettier gives it ---
const output = {};
for (const [addr, e] of enriched) {
output[addr] = { name: e.name, url: e.url };
}
fs.writeFileSync(OUTPUT, JSON.stringify(output, null, 4) + "\n");
console.error(`Done! ${tokens.length} names, ${urlsFound} URLs.`);
}
main().catch((err) => {
console.error(`enrich-tokens: ${err.message}`);
process.exit(1);
});
-203
View File
@@ -1,203 +0,0 @@
// Draws the toolbar icon and writes it to every file manifest/chrome.json
// declares under "icons". Run by `make icons`; tests/icons.test.js checks that
// the committed files hold exactly the image this draws.
//
// The icon is a dark-navy rounded square carrying a teal triangle with a
// smaller triangle cut out of it. Every coordinate below is a fraction of the
// icon's side, so one drawing serves every size. A pixel's colour is the
// average of an 8x8 grid of samples, one at the centre of each cell, which
// smooths the edges.
//
// The PNG is written here rather than by a library: RGBA at 8 bits per
// channel, filter type 0 (none) on every row, one IDAT chunk compressed by
// node's zlib at level 9. The committed files were compressed by stock zlib,
// which node's bundled zlib does not reproduce byte for byte, so the image is
// compared rather than the file: the IHDR and every pixel.
"use strict";
const fs = require("fs");
const path = require("path");
const zlib = require("zlib");
const { icons } = require("../../manifest/chrome.json");
const NAVY = [0x10, 0x1a, 0x2e];
const TEAL = [0x35, 0xe0, 0xc2];
const CORNER_RADIUS = 0.22;
const OUTER_TRIANGLE = [
[0.5, 0.15],
[0.115, 0.855],
[0.885, 0.855],
];
const INNER_TRIANGLE = [
[0.5, 0.4],
[0.29, 0.7],
[0.71, 0.7],
];
const SAMPLES_PER_SIDE = 8;
const PNG_SIGNATURE = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
]);
// Points are in pixels from the icon's top-left corner.
function insideRoundedSquare(x, y, size) {
const r = CORNER_RADIUS * size;
// How far the point is past the start of a corner's curve, on each axis.
const dx = Math.max(r - x, 0, x - (size - r));
const dy = Math.max(r - y, 0, y - (size - r));
return dx * dx + dy * dy <= r * r;
}
// Inside or on an edge: the point is not on opposite sides of two edges.
function insideTriangle(x, y, [a, b, c]) {
const side = (p, q) =>
(q[0] - p[0]) * (y - p[1]) - (q[1] - p[1]) * (x - p[0]);
const sides = [side(a, b), side(b, c), side(c, a)];
return !(sides.some((s) => s < 0) && sides.some((s) => s > 0));
}
// Rounds to the nearest integer and a half to the even neighbour, as the
// committed icons were made. Math.round takes a half up, which would change
// some pixels by one.
function roundHalfToEven(v) {
const down = Math.floor(v);
if (v - down !== 0.5) {
return Math.round(v);
}
return down % 2 === 0 ? down : down + 1;
}
// The RGBA bytes of the pixel whose top-left corner is (px, py).
function pixel(px, py, size, outer, inner) {
let inSquare = 0;
let inTeal = 0;
for (let j = 0; j < SAMPLES_PER_SIDE; j++) {
const y = py + (j + 0.5) / SAMPLES_PER_SIDE;
for (let i = 0; i < SAMPLES_PER_SIDE; i++) {
const x = px + (i + 0.5) / SAMPLES_PER_SIDE;
if (!insideRoundedSquare(x, y, size)) {
continue;
}
inSquare++;
if (insideTriangle(x, y, outer) && !insideTriangle(x, y, inner)) {
inTeal++;
}
}
}
if (inSquare === 0) {
return [0, 0, 0, 0];
}
const colour = NAVY.map((navy, k) =>
roundHalfToEven(navy + ((TEAL[k] - navy) * inTeal) / inSquare),
);
const alpha = roundHalfToEven((255 * inSquare) / SAMPLES_PER_SIDE ** 2);
return [...colour, alpha];
}
// A PNG chunk: the data's length, the type, the data, then the CRC-32 of the
// type and the data.
function chunk(type, data) {
const typeAndData = Buffer.concat([Buffer.from(type, "ascii"), data]);
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(zlib.crc32(typeAndData));
return Buffer.concat([length, typeAndData, crc]);
}
// The complete PNG file for the icon at `size` pixels square.
function drawIcon(size) {
const toPixels = (corners) => corners.map(([x, y]) => [x * size, y * size]);
const outer = toPixels(OUTER_TRIANGLE);
const inner = toPixels(INNER_TRIANGLE);
const rows = [];
for (let py = 0; py < size; py++) {
const row = [0]; // filter type 0: the row's bytes are stored as they are
for (let px = 0; px < size; px++) {
row.push(...pixel(px, py, size, outer, inner));
}
rows.push(Buffer.from(row));
}
const header = Buffer.alloc(13); // compression, filter, interlace: all 0
header.writeUInt32BE(size, 0); // width
header.writeUInt32BE(size, 4); // height
header[8] = 8; // bits per channel
header[9] = 6; // colour type: RGBA
return Buffer.concat([
PNG_SIGNATURE,
chunk("IHDR", header),
chunk("IDAT", zlib.deflateSync(Buffer.concat(rows), { level: 9 })),
chunk("IEND", Buffer.alloc(0)),
]);
}
// The image in a PNG: its IHDR data, and its rows after decompression, each
// row's filter type byte first. With filter type 0 on every row, as drawIcon
// writes, the rows are the pixels themselves; a file using another filter does
// not match even where its pixels do. Refuses a file that is not a PNG, a chunk
// whose CRC-32 is wrong, and any chunk but IHDR, IDAT and IEND, rather than
// ignoring what it cannot compare.
function decodePng(png) {
if (!png.subarray(0, 8).equals(PNG_SIGNATURE)) {
throw new Error("not a PNG");
}
let header = null;
const idat = [];
for (let pos = 8; pos < png.length; ) {
const length = png.readUInt32BE(pos);
const typeAndData = png.subarray(pos + 4, pos + 8 + length);
const type = typeAndData.toString("ascii", 0, 4);
if (zlib.crc32(typeAndData) !== png.readUInt32BE(pos + 8 + length)) {
throw new Error(`the ${type} chunk fails its CRC-32`);
}
if (type === "IHDR") {
header = typeAndData.subarray(4);
} else if (type === "IDAT") {
idat.push(typeAndData.subarray(4));
} else if (type !== "IEND") {
throw new Error(`unexpected ${type} chunk`);
}
pos += 12 + length;
}
if (header === null) {
throw new Error("no IHDR chunk");
}
return { header, rows: zlib.inflateSync(Buffer.concat(idat)) };
}
// True when `file` already holds the image in `png`. A file that is missing or
// cannot be read as a PNG does not, and is written over.
function holdsSameImage(file, png) {
let existing;
try {
existing = decodePng(fs.readFileSync(file));
} catch {
return false;
}
const drawn = decodePng(png);
return (
existing.header.equals(drawn.header) && existing.rows.equals(drawn.rows)
);
}
// A file already holding the drawn image is left alone, so running this does
// not rewrite the committed icons with node's compression.
if (require.main === module) {
for (const [size, file] of Object.entries(icons)) {
const target = path.join(__dirname, "..", "..", file);
const png = drawIcon(Number(size));
if (holdsSameImage(target, png)) {
console.log(`icons: ${file} already holds this image`);
continue;
}
fs.writeFileSync(target, png);
console.log(`icons: wrote ${file}`);
}
}
module.exports = { drawIcon, decodePng };
File diff suppressed because it is too large Load Diff
+23 -103
View File
@@ -22,7 +22,6 @@ const { debugFetch, log } = require("../shared/log");
const { const {
verifySignedTx, verifySignedTx,
verifySignature, verifySignature,
typedDataChainRefusal,
failureIsRetryable, failureIsRetryable,
describeTxFailure, describeTxFailure,
sameAddress, sameAddress,
@@ -138,12 +137,11 @@ function releaseTxApprovalSlotFor(approvalId) {
} }
} }
// One site-connection approval, one sign approval and one network-switch // One site-connection approval and one sign approval per site at a time: a
// approval per site at a time: a page that asks again before the user has // page that asks again before the user has answered is refused with the code
// answered is refused with the code above instead of opening another window, // above instead of opening another window, so it cannot bury the user in
// so it cannot bury the user in prompts. The pending approval itself holds the // prompts. The pending approval itself holds the place, so a caller must test
// place, so a caller must test this and raise its approval with nothing awaited // this and raise its approval with nothing awaited in between.
// in between.
function findPendingApproval(origin, type) { function findPendingApproval(origin, type) {
return Object.values(pendingApprovals).find( return Object.values(pendingApprovals).find(
(approval) => approval.origin === origin && approval.type === type, (approval) => approval.origin === origin && approval.type === type,
@@ -350,9 +348,8 @@ function settleApproval(id, result, options) {
// What a pending approval resolves to when it is given up on rather than // What a pending approval resolves to when it is given up on rather than
// answered: the window was closed, or could not be opened at all. A tx or sign // answered: the window was closed, or could not be opened at all. A tx or sign
// approval answers the requesting page in EIP-1193 shape; a site-connection or // approval answers the requesting page in EIP-1193 shape; a site-connection
// network-switch approval answers its handler in the shape the popup's // approval answers the connection handler in its own.
// decision has, as a refusal.
function abandonedResult(approval, code, message) { function abandonedResult(approval, code, message) {
if (approval.type === "tx" || approval.type === "sign") { if (approval.type === "tx" || approval.type === "sign") {
return { error: { code, message } }; return { error: { code, message } };
@@ -591,26 +588,6 @@ function requestSignApproval(origin, signParams, approvedFrom) {
}); });
} }
// Open a network-switch approval popup and return a promise that resolves with
// { approved }. Opened in a window, as tx and sign approvals are, because a
// site's request is not a user gesture. The popup answers on the approval port,
// as a site-connection approval does.
function requestNetworkApproval(origin, currentNetworkId, requestedNetworkId) {
return new Promise((resolve) => {
const id = crypto.randomUUID();
pendingApprovals[id] = {
id,
origin,
currentNetworkId,
requestedNetworkId,
resolve,
type: "network",
};
openApprovalWindow(id);
});
}
// Anything only the extension's own pages may say. A content script speaks // Anything only the extension's own pages may say. A content script speaks
// with the page's URL, so this is what separates the popup from the site the // with the page's URL, so this is what separates the popup from the site the
// popup is being asked about. // popup is being asked about.
@@ -620,8 +597,8 @@ function isExtensionSender(sender) {
} }
// The approval popup's port: it carries the user's decision on a // The approval popup's port: it carries the user's decision on a
// site-connection or network-switch approval, and its disconnect is how that // site-connection approval, and its disconnect is how that approval learns the
// approval learns the popup closed without one. // popup closed without one.
// //
// The decision travels this port rather than a one-off runtime.sendMessage() // The decision travels this port rather than a one-off runtime.sendMessage()
// for exactly one reason: the port is also what the popup's window.close() // for exactly one reason: the port is also what the popup's window.close()
@@ -633,9 +610,8 @@ function isExtensionSender(sender) {
// had refused. // had refused.
// //
// TX and sign approvals do not decide here. They stay pending across a // TX and sign approvals do not decide here. They stay pending across a
// disconnect. When their window closes, the windows.onRemoved listener below // disconnect — the user can reopen the toolbar popup — and are rejected by the
// rejects them, unless an approved attempt is still running, in which case // windows.onRemoved listener below.
// that attempt answers the page.
runtime.onConnect.addListener((port) => { runtime.onConnect.addListener((port) => {
if (port.name.startsWith("approval:")) { if (port.name.startsWith("approval:")) {
const id = port.name.split(":")[1]; const id = port.name.split(":")[1];
@@ -663,9 +639,7 @@ runtime.onConnect.addListener((port) => {
const approval = pendingApprovals[id]; const approval = pendingApprovals[id];
if (approval) { if (approval) {
if (approval.type === "tx" || approval.type === "sign") { if (approval.type === "tx" || approval.type === "sign") {
// When its window closes, windows.onRemoved rejects it, // Keep pending — user can reopen the toolbar popup
// unless an approved attempt is still running, in which
// case that attempt answers the page.
return; return;
} }
settleApproval(id, { approved: false, remember: false }); settleApproval(id, { approved: false, remember: false });
@@ -832,10 +806,6 @@ async function handleRpc(method, params, origin) {
// tab is served from, so a page the user never connected to must // tab is served from, so a page the user never connected to must
// not be able to do it. Ungated, any page could clear the // not be able to do it. Ungated, any page could clear the
// [TESTNET] banner under a user who believed they were on Sepolia. // [TESTNET] banner under a user who believed they were on Sepolia.
//
// A connected site does not switch it either: only the user does,
// by approving the request on the prompt below
// (https://git.eeqj.de/sneak/AutistMask/issues/408).
const s = await getState(); const s = await getState();
const activeAddress = activeAddressOf(s); const activeAddress = activeAddressOf(s);
const allowed = s.allowedSites[activeAddress] || []; const allowed = s.allowedSites[activeAddress] || [];
@@ -857,27 +827,6 @@ async function handleRpc(method, params, origin) {
} }
if (SUPPORTED_CHAIN_IDS.has(chainId)) { if (SUPPORTED_CHAIN_IDS.has(chainId)) {
const target = networkByChainId(chainId); const target = networkByChainId(chainId);
if (findPendingApproval(origin, "network")) {
return {
error: {
code: APPROVAL_PENDING_CODE,
message: APPROVAL_PENDING_MESSAGE,
},
};
}
const decision = await requestNetworkApproval(
origin,
s.networkId,
target.id,
);
if (!decision.approved) {
return {
error: {
code: APPROVAL_REJECTED_CODE,
message: APPROVAL_REJECTED_MESSAGE,
},
};
}
// Read-modify-write against storage. The old path went through // Read-modify-write against storage. The old path went through
// onChainSwitch(), which mutates the singleton and then persists // onChainSwitch(), which mutates the singleton and then persists
// every field of it — on an unloaded worker that wrote empty // every field of it — on an unloaded worker that wrote empty
@@ -1396,21 +1345,20 @@ startBackgroundJobs();
// which then fails retryably settles instead of waiting in a window that no // which then fails retryably settles instead of waiting in a window that no
// longer exists. // longer exists.
// //
// A site-connection or network-switch approval whose popup connected its port // A site-connection approval whose popup connected its port is not decided
// is not decided here. That popup approves and closes in the same breath, and // here. That popup approves and closes in the same breath, and this event
// this event races the decision on a channel of its own — the same race the // races the decision on a channel of its own — the same race the port exists
// port exists to end. Its port disconnect says the same thing this event does, // to end. Its port disconnect says the same thing this event does, in an order
// in an order that is defined, so the disconnect is left to say it. The window // that is defined, so the disconnect is left to say it. The window closing
// closing before any port connected is the one case with nothing else to speak // before any port connected is the one case with nothing else to speak for it,
// for it, and is rejected here so the dApp is not left waiting on a window that // and is rejected here so the dApp is not left waiting on a window that is
// is gone. // gone.
if (windowsNs && windowsNs.onRemoved) { if (windowsNs && windowsNs.onRemoved) {
windowsNs.onRemoved.addListener((windowId) => { windowsNs.onRemoved.addListener((windowId) => {
for (const [id, approval] of Object.entries(pendingApprovals)) { for (const [id, approval] of Object.entries(pendingApprovals)) {
if (approval.windowId !== windowId) continue; if (approval.windowId !== windowId) continue;
const decidedOnPort = const isSite = approval.type !== "tx" && approval.type !== "sign";
approval.type !== "tx" && approval.type !== "sign"; if (isSite && approval.portConnected) continue;
if (decidedOnPort && approval.portConnected) continue;
const rejection = abandonedResult( const rejection = abandonedResult(
approval, approval,
APPROVAL_REJECTED_CODE, APPROVAL_REJECTED_CODE,
@@ -1475,7 +1423,6 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
"AUTISTMASK_ADDRESSES_REMOVED", "AUTISTMASK_ADDRESSES_REMOVED",
"AUTISTMASK_GET_CONNECTED_SITES", "AUTISTMASK_GET_CONNECTED_SITES",
"AUTISTMASK_REMOVE_SITE", "AUTISTMASK_REMOVE_SITE",
"AUTISTMASK_NETWORK_CHANGED",
]; ];
if (POPUP_ONLY_TYPES.includes(msg.type) && !isExtensionSender(sender)) { if (POPUP_ONLY_TYPES.includes(msg.type) && !isExtensionSender(sender)) {
sendResponse({ error: "Unauthorized sender" }); sendResponse({ error: "Unauthorized sender" });
@@ -1499,11 +1446,6 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
resp.signParams = approval.signParams; resp.signParams = approval.signParams;
resp.approvedFrom = approval.approvedFrom; resp.approvedFrom = approval.approvedFrom;
} }
if (approval.type === "network") {
resp.type = "network";
resp.currentNetworkId = approval.currentNetworkId;
resp.requestedNetworkId = approval.requestedNetworkId;
}
// Flag if the requesting domain is on the phishing blocklist. // Flag if the requesting domain is on the phishing blocklist.
resp.isPhishingDomain = isPhishingDomain( resp.isPhishingDomain = isPhishingDomain(
extractHostname(approval.origin), extractHostname(approval.origin),
@@ -1768,8 +1710,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
(async () => { (async () => {
try { try {
const s = await getState(); const activeAddress = await getActiveAddress();
const activeAddress = activeAddressOf(s);
// Same as the transaction path: the address the approval named // Same as the transaction path: the address the approval named
// is the one that must have signed, and a switch since then is // is the one that must have signed, and a switch since then is
// a refusal rather than a signature from another account. // a refusal rather than a signature from another account.
@@ -1788,20 +1729,6 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
signature, signature,
approval.approvedFrom, approval.approvedFrom,
); );
// The sign screen refuses typed data whose domain names
// another chain. A signature for it that arrives anyway is
// refused here, against the network active now.
const sp = approval.signParams;
if (
sp.method === "eth_signTypedData_v4" ||
sp.method === "eth_signTypedData"
) {
const refusal = typedDataChainRefusal(
JSON.parse(sp.typedData).domain,
networkById(s.networkId),
);
if (refusal) throw new ApprovalMismatchError(refusal);
}
settleApproval(msg.id, { signature }, { holdsClaim: true }); settleApproval(msg.id, { signature }, { holdsClaim: true });
sendResponse({ signature }); sendResponse({ signature });
} catch (e) { } catch (e) {
@@ -1874,13 +1801,6 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
broadcastSiteRemoved(msg.origin); broadcastSiteRemoved(msg.origin);
return false; return false;
} }
// Settings switched the network and has saved it. Open tabs are told the
// new chain id the same way as after a site's approved switch request.
if (msg.type === "AUTISTMASK_NETWORK_CHANGED") {
broadcastChainChanged(msg.chainId);
return false;
}
}); });
module.exports = { PROXY_METHODS }; module.exports = { PROXY_METHODS };
+1 -4
View File
@@ -99,10 +99,7 @@
const provider = { const provider = {
isAutistMask: true, isAutistMask: true,
// An interface-compatibility flag many dApps check. Kept as a written isMetaMask: true, // compatibility — many dApps check this
// exception to the rule that no competitor is named in code: see
// Policies in README.md, and RULES.md.
isMetaMask: true,
chainId: currentChainId, chainId: currentChainId,
networkVersion: currentNetworkVersion, networkVersion: currentNetworkVersion,
selectedAddress: null, selectedAddress: null,
+28 -90
View File
@@ -7,10 +7,9 @@
<link rel="stylesheet" href="styles.css" /> <link rel="stylesheet" href="styles.css" />
</head> </head>
<!-- Chrome gives extension pages a stylesheet of its own that sets the <!-- Chrome gives extension pages a stylesheet of its own that sets the
font and its size on body, and a Tailwind class beats it only when font on body, and a Tailwind class beats it only when marked
marked important: hence font-mono! and text-sm! rather than important: hence font-mono! rather than font-mono. -->
font-mono and text-sm. --> <body class="bg-bg text-fg font-mono! text-sm">
<body class="bg-bg text-fg font-mono! text-sm!">
<div id="app" class="p-2 pr-5 overflow-x-hidden"> <div id="app" class="p-2 pr-5 overflow-x-hidden">
<!-- ============ GLOBAL TITLE BAR ============ --> <!-- ============ GLOBAL TITLE BAR ============ -->
<div <div
@@ -88,14 +87,11 @@
<!-- Mnemonic form section --> <!-- Mnemonic form section -->
<div id="add-wallet-section-mnemonic"> <div id="add-wallet-section-mnemonic">
<!-- The die button shares the instruction's row: on a row <p class="mb-2">
of its own it pushes Import below the fold at Enter your 12 or 24 word recovery phrase below, or click
360x600. --> the button to roll the die for a new one.
<div class="mb-2 flex justify-between items-start gap-2"> </p>
<p> <div class="mb-1 flex justify-end">
Enter your 12 or 24 word recovery phrase below, or
click the button to roll the die for a new one.
</p>
<button <button
id="btn-generate-phrase" id="btn-generate-phrase"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer text-xs" class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer text-xs"
@@ -211,22 +207,12 @@
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg" class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/> />
</div> </div>
<!-- The error line sits beside Import, not above it: at <button
360x600 the button already starts near the bottom of id="btn-add-wallet-confirm"
the popup, and a line of its own would push it below class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
the fold. The longest error fits on one line here. --> >
<div class="flex items-center gap-2"> Import
<button </button>
id="btn-add-wallet-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Import
</button>
<div
id="add-wallet-password-error"
class="text-xs min-h-[1.25rem] invisible"
></div>
</div>
</div> </div>
<!-- ============ MAIN VIEW: ALL WALLETS & ADDRESSES ============ --> <!-- ============ MAIN VIEW: ALL WALLETS & ADDRESSES ============ -->
@@ -410,6 +396,10 @@
Warning: anyone with this private key can access and Warning: anyone with this private key can access and
transfer all funds from this address. Never share it. transfer all funds from this address. Never share it.
</p> </p>
<div
id="export-privkey-flash"
class="text-xs mb-2 min-h-[1.25rem] invisible"
></div>
<div id="export-privkey-password-section" class="mb-2"> <div id="export-privkey-password-section" class="mb-2">
<label class="block mb-1">Password</label> <label class="block mb-1">Password</label>
<input <input
@@ -418,13 +408,9 @@
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg" class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="Enter your password to continue" placeholder="Enter your password to continue"
/> />
<div
id="export-privkey-password-error"
class="text-xs mt-2 mb-2 min-h-[1.25rem] invisible"
></div>
<button <button
id="btn-export-privkey-confirm" id="btn-export-privkey-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer" class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mt-2"
> >
Reveal Reveal
</button> </button>
@@ -1130,6 +1116,10 @@
<strong id="delete-wallet-name"></strong> is permanent. Any <strong id="delete-wallet-name"></strong> is permanent. Any
funds will be unrecoverable without your recovery phrase. funds will be unrecoverable without your recovery phrase.
</p> </p>
<div
id="delete-wallet-flash"
class="text-xs text-red-500 mb-2 min-h-[1.25rem] invisible"
></div>
<div class="mb-2"> <div class="mb-2">
<label class="block mb-1">Password</label> <label class="block mb-1">Password</label>
<input <input
@@ -1139,10 +1129,6 @@
placeholder="Enter your password to confirm" placeholder="Enter your password to confirm"
/> />
</div> </div>
<div
id="delete-wallet-password-error"
class="text-xs mb-2 min-h-[1.25rem] invisible"
></div>
<button <button
id="btn-delete-wallet-confirm" id="btn-delete-wallet-confirm"
class="border border-border text-red-500 px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer" class="border border-border text-red-500 px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
@@ -1287,6 +1273,10 @@
this wallet, from any device, without your password. Never this wallet, from any device, without your password. Never
type them into a website and never show them to anyone. type them into a website and never show them to anyone.
</div> </div>
<div
id="show-phrase-flash"
class="text-xs text-red-500 mb-2 min-h-[1.25rem] invisible"
></div>
<div id="show-phrase-password-section" class="mb-2"> <div id="show-phrase-password-section" class="mb-2">
<label class="block mb-1">Password</label> <label class="block mb-1">Password</label>
<input <input
@@ -1295,13 +1285,9 @@
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg" class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="Enter your password to continue" placeholder="Enter your password to continue"
/> />
<div
id="show-phrase-password-error"
class="text-xs mt-2 mb-2 min-h-[1.25rem] invisible"
></div>
<button <button
id="btn-show-phrase-reveal" id="btn-show-phrase-reveal"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer" class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mt-2"
> >
Reveal Reveal
</button> </button>
@@ -1745,54 +1731,6 @@
</div> </div>
</div> </div>
<!-- ============ NETWORK SWITCH APPROVAL ============ -->
<div id="view-approve-network" class="view hidden">
<h2 class="font-bold mb-2">Network Switch Request</h2>
<div
id="approve-network-phishing-warning"
class="mb-3 p-2 text-xs font-bold hidden bg-red-100 text-red-800 border-2 border-red-600 rounded-md"
>
⚠️ PHISHING WARNING: This site is on a known phishing
blocklist. Proceed with extreme caution.
</div>
<p class="mb-2">
<span id="approve-network-origin" class="font-bold"></span>
wants to switch the wallet's network.
</p>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Current network</div>
<div
id="approve-network-current"
class="text-xs font-bold"
></div>
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Requested network</div>
<div
id="approve-network-requested"
class="text-xs font-bold"
></div>
</div>
<p class="mb-3 text-xs">
Switching changes the network for the whole wallet and for
every site, not only for this one.
</p>
<div class="flex justify-between">
<button
id="btn-approve-network"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Switch
</button>
<button
id="btn-reject-network"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Reject
</button>
</div>
</div>
<!-- ============ STATE RECOVERY ============ --> <!-- ============ STATE RECOVERY ============ -->
<!-- <!--
Shown when the stored profile cannot be read at all. Every Shown when the stored profile cannot be read at all. Every
+2 -2
View File
@@ -238,9 +238,9 @@ async function init() {
// rejection rather than an uncaught error — measured as still failing // rejection rather than an uncaught error — measured as still failing
// the run on both harnesses (Playwright `pageerror`, and the Firefox // the run on both harnesses (Playwright `pageerror`, and the Firefox
// driver's console-service drain), so nothing is lost by leaving it // driver's console-service drain), so nothing is lost by leaving it
// on that path. show() puts the approval's own screen up once the // on that path.
// background has described it.
approval.show(approvalId); approval.show(approvalId);
showView("approve-site");
return; return;
} }
+4 -12
View File
@@ -2,8 +2,6 @@ const {
$, $,
showView, showView,
showFlash, showFlash,
showError,
hideError,
goBack, goBack,
clearViewStack, clearViewStack,
onViewLeave, onViewLeave,
@@ -100,7 +98,6 @@ function clear() {
$("add-wallet-password").value = ""; $("add-wallet-password").value = "";
$("add-wallet-password-confirm").value = ""; $("add-wallet-password-confirm").value = "";
$("add-wallet-phrase-warning").style.visibility = "hidden"; $("add-wallet-phrase-warning").style.visibility = "hidden";
hideError("add-wallet-password-error");
} }
// Each wallet has its own password (its own encryptedSecret), so adding a // Each wallet has its own password (its own encryptedSecret), so adding a
@@ -128,18 +125,15 @@ function validatePassword() {
const pw = $("add-wallet-password").value; const pw = $("add-wallet-password").value;
const pw2 = $("add-wallet-password-confirm").value; const pw2 = $("add-wallet-password-confirm").value;
if (!pw) { if (!pw) {
showError("add-wallet-password-error", "Please choose a password."); showFlash("Please choose a password.");
return null; return null;
} }
if (pw.length < 12) { if (pw.length < 12) {
showError( showFlash("Password must be at least 12 characters.");
"add-wallet-password-error",
"Password must be at least 12 characters.",
);
return null; return null;
} }
if (pw !== pw2) { if (pw !== pw2) {
showError("add-wallet-password-error", "Passwords do not match."); showFlash("Passwords do not match.");
return null; return null;
} }
return pw; return pw;
@@ -348,10 +342,8 @@ function init(ctx) {
$("add-wallet-phrase-warning").style.visibility = "visible"; $("add-wallet-phrase-warning").style.visibility = "visible";
}); });
// Import / confirm. Each press starts with no password error on screen: // Import / confirm
// validatePassword() puts it back if the password is still wrong.
$("btn-add-wallet-confirm").addEventListener("click", async () => { $("btn-add-wallet-confirm").addEventListener("click", async () => {
hideError("add-wallet-password-error");
if (currentMode === "mnemonic") { if (currentMode === "mnemonic") {
await importMnemonic(ctx); await importMnemonic(ctx);
} else if (currentMode === "privkey") { } else if (currentMode === "privkey") {
+18 -57
View File
@@ -12,9 +12,8 @@ const {
formatFee, formatFee,
tokenLabel, tokenLabel,
} = require("./helpers"); } = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { const {
networkById,
networkByChainId, networkByChainId,
nativeCurrencyByChainId, nativeCurrencyByChainId,
} = require("../../shared/networks"); } = require("../../shared/networks");
@@ -46,10 +45,7 @@ const {
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 { const { describeSigningFailure } = require("../../shared/approvalVerify");
describeSigningFailure,
typedDataChainRefusal,
} = require("../../shared/approvalVerify");
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 { notify, runtimeApi, sendMessage } = require("../../shared/browserApi");
@@ -332,9 +328,9 @@ function showTxApproval(details) {
const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null; const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null;
const usdStr = formatUsd(ethUsd); const usdStr = formatUsd(ethUsd);
// In the native currency of the network the transaction is for, which the // In the native currency of the network the transaction is for, which the
// Network line names, not the active network's: the active network can // Network line names, not the active network's: a site can switch the
// change, in Settings or when the user approves a site's request, after // active network after this transaction is prepared and back before it is
// this transaction is prepared and change back before it is signed. // signed.
$("approve-tx-value").textContent = $("approve-tx-value").textContent =
ethValueFormatted + ethValueFormatted +
" " + " " +
@@ -446,8 +442,7 @@ function signedPrimaryType(types) {
// Why a signature request cannot be signed, as a sentence for the error line, // Why a signature request cannot be signed, as a sentence for the error line,
// or null when it can. Only typed data is refused: the `primaryType` the page // or null when it can. Only typed data is refused: the `primaryType` the page
// states has to be the type ethers will sign, or this screen would name one // states has to be the type ethers will sign, or this screen would name one
// message while another is signed. Its domain may name no chain but the active // message while another is signed.
// network's.
function typedDataRefusal(sp) { function typedDataRefusal(sp) {
if (sp.method === "personal_sign" || sp.method === "eth_sign") return null; if (sp.method === "personal_sign" || sp.method === "eth_sign") return null;
let data; let data;
@@ -470,7 +465,7 @@ function typedDataRefusal(sp) {
", so it cannot be signed." ", so it cannot be signed."
); );
} }
return typedDataChainRefusal(data.domain, currentNetwork()); return null;
} }
// The largest amount a Permit2 allowance can hold, a uint160. Permit2 treats // The largest amount a Permit2 allowance can hold, a uint160. Permit2 treats
@@ -757,29 +752,9 @@ function showSignApproval(details) {
); );
} }
function showNetworkApproval(details) {
showPhishingWarning(
"approve-network-phishing-warning",
details.isPhishingDomain,
);
$("approve-network-origin").textContent = details.origin;
$("approve-network-current").textContent = networkById(
details.currentNetworkId,
).name;
$("approve-network-requested").textContent = networkById(
details.requestedNetworkId,
).name;
showView("approve-network");
}
// Awaited by nobody: the popup entry point calls this and moves on. It // Awaited by nobody: the popup entry point calls this and moves on. It
// therefore has to absorb its own failure, and a background that cannot // 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. // describe the approval is the same outcome as an approval that is gone.
//
// Nothing is on screen until the background has described the approval, so
// the screen shown is always the one for the approval this window answers:
// the connection prompt's "Allow" and the network switch prompt's "Switch"
// answer on the same port, and either would approve the other.
async function show(id) { async function show(id) {
approvalId = id; approvalId = id;
approvalPort = runtimeApi().connect({ name: "approval:" + id }); approvalPort = runtimeApi().connect({ name: "approval:" + id });
@@ -803,10 +778,6 @@ async function show(id) {
showSignApproval(details); showSignApproval(details);
return; return;
} }
if (details.type === "network") {
showNetworkApproval(details);
return;
}
// Site connection approval // Site connection approval
showPhishingWarning( showPhishingWarning(
"approve-site-phishing-warning", "approve-site-phishing-warning",
@@ -816,7 +787,6 @@ async function show(id) {
$("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;
showView("approve-site");
} }
let approvalId = null; let approvalId = null;
@@ -896,21 +866,20 @@ function clearSignPassword() {
hideError("approve-sign-error"); hideError("approve-sign-error");
} }
// Answer a site-connection or network-switch approval and close. The decision // Answer a site-connection approval and close. The decision goes out on the
// goes out on the approval port — see approvalPort above for why — and carries // approval port — see approvalPort above for why — and carries no approval id,
// no approval id, because the port name already names the approval the // because the port name already names the approval the background will settle.
// background will settle. `remember` means something only for a site // The post is guarded because a throw must not cost the close: posting on a
// connection. The post is guarded because a throw must not cost the close: // port whose background worker has been torn down throws, and the approval it
// posting on a port whose background worker has been torn down throws, and the // would have settled died with that worker, so the only thing left to do is
// approval it would have settled died with that worker, so the only thing left // what the user asked for — go away.
// to do is what the user asked for — go away. function decideSite(approved) {
function decide(approved, remember) {
if (approvalPort) { if (approvalPort) {
try { try {
approvalPort.postMessage({ approvalPort.postMessage({
type: "AUTISTMASK_APPROVAL_DECISION", type: "AUTISTMASK_APPROVAL_DECISION",
approved, approved,
remember, remember: $("approve-remember").checked,
}); });
} catch { } catch {
// Nothing to report it to; the window closes either way. // Nothing to report it to; the window closes either way.
@@ -929,19 +898,11 @@ function init(_ctx) {
}); });
$("btn-approve").addEventListener("click", () => { $("btn-approve").addEventListener("click", () => {
decide(true, $("approve-remember").checked); decideSite(true);
}); });
$("btn-reject").addEventListener("click", () => { $("btn-reject").addEventListener("click", () => {
decide(false, $("approve-remember").checked); decideSite(false);
});
$("btn-approve-network").addEventListener("click", () => {
decide(true, false);
});
$("btn-reject-network").addEventListener("click", () => {
decide(false, false);
}); });
$("btn-approve-tx").addEventListener("click", async () => { $("btn-approve-tx").addEventListener("click", async () => {
+11 -16
View File
@@ -2,8 +2,6 @@ const {
$, $,
showView, showView,
showFlash, showFlash,
showError,
hideError,
goBack, goBack,
clearViewStack, clearViewStack,
onViewLeave, onViewLeave,
@@ -57,7 +55,8 @@ function confirmKey(name) {
function clear() { function clear() {
deleteWalletIndex = null; deleteWalletIndex = null;
$("delete-wallet-password").value = ""; $("delete-wallet-password").value = "";
hideError("delete-wallet-password-error"); $("delete-wallet-flash").textContent = "";
$("delete-wallet-flash").style.visibility = "hidden";
} }
// The lost-password screen holds no secret — a wallet name is not one — // The lost-password screen holds no secret — a wallet name is not one —
@@ -233,22 +232,19 @@ function init(_ctx) {
$("btn-delete-wallet-confirm").addEventListener("click", async () => { $("btn-delete-wallet-confirm").addEventListener("click", async () => {
const pw = $("delete-wallet-password").value; const pw = $("delete-wallet-password").value;
if (!pw) { if (!pw) {
showError( $("delete-wallet-flash").textContent =
"delete-wallet-password-error", "Please enter your password.";
"Please enter your password.", $("delete-wallet-flash").style.visibility = "visible";
);
return; return;
} }
if (deleteWalletIndex === null) { if (deleteWalletIndex === null) {
showError( $("delete-wallet-flash").textContent =
"delete-wallet-password-error", "No wallet selected for deletion.";
"No wallet selected for deletion.", $("delete-wallet-flash").style.visibility = "visible";
);
return; return;
} }
hideError("delete-wallet-password-error");
const btn = $("btn-delete-wallet-confirm"); const btn = $("btn-delete-wallet-confirm");
btn.disabled = true; btn.disabled = true;
btn.classList.add("text-muted"); btn.classList.add("text-muted");
@@ -260,10 +256,9 @@ function init(_ctx) {
try { try {
await decryptWithPassword(wallet.encryptedSecret, pw); await decryptWithPassword(wallet.encryptedSecret, pw);
} catch { } catch {
showError( $("delete-wallet-flash").textContent =
"delete-wallet-password-error", "That password is incorrect. Please try again.";
"That password is incorrect. Please try again.", $("delete-wallet-flash").style.visibility = "visible";
);
btn.disabled = false; btn.disabled = false;
btn.classList.remove("text-muted"); btn.classList.remove("text-muted");
return; return;
+12 -13
View File
@@ -20,8 +20,6 @@ const {
$, $,
showView, showView,
showFlash, showFlash,
showError,
hideError,
flashCopyFeedback, flashCopyFeedback,
goBack, goBack,
onViewLeave, onViewLeave,
@@ -58,6 +56,11 @@ function isCurrentReveal(generation) {
); );
} }
function fail(message) {
$("export-privkey-flash").textContent = message;
$("export-privkey-flash").style.visibility = "visible";
}
// Wipe every trace of the key and drop the address selection. Safe to call // Wipe every trace of the key and drop the address selection. Safe to call
// when nothing was ever revealed, and safe to call twice. // when nothing was ever revealed, and safe to call twice.
function clear() { function clear() {
@@ -68,7 +71,8 @@ function clear() {
$("export-privkey-password").value = ""; $("export-privkey-password").value = "";
$("export-privkey-result").classList.add("hidden"); $("export-privkey-result").classList.add("hidden");
$("export-privkey-password-section").classList.remove("hidden"); $("export-privkey-password-section").classList.remove("hidden");
hideError("export-privkey-password-error"); $("export-privkey-flash").textContent = "";
$("export-privkey-flash").style.visibility = "hidden";
} }
function show(walletIdx, addrIdx) { function show(walletIdx, addrIdx) {
@@ -108,19 +112,15 @@ function show(walletIdx, addrIdx) {
async function reveal() { async function reveal() {
const password = $("export-privkey-password").value; const password = $("export-privkey-password").value;
if (!password) { if (!password) {
showError( fail("Please enter your password.");
"export-privkey-password-error",
"Please enter your password.",
);
return; return;
} }
if (walletIndex === null) { if (walletIndex === null) {
showError("export-privkey-password-error", "No address is selected."); fail("No address is selected.");
return; return;
} }
const wallet = state.wallets[walletIndex]; const wallet = state.wallets[walletIndex];
hideError("export-privkey-password-error");
const btn = $("btn-export-privkey-confirm"); const btn = $("btn-export-privkey-confirm");
btn.disabled = true; btn.disabled = true;
btn.classList.add("text-muted"); btn.classList.add("text-muted");
@@ -140,12 +140,11 @@ async function reveal() {
$("export-privkey-password-section").classList.add("hidden"); $("export-privkey-password-section").classList.add("hidden");
$("export-privkey-value").textContent = signer.privateKey; $("export-privkey-value").textContent = signer.privateKey;
$("export-privkey-result").classList.remove("hidden"); $("export-privkey-result").classList.remove("hidden");
$("export-privkey-flash").textContent = "";
$("export-privkey-flash").style.visibility = "hidden";
} catch { } catch {
if (!isCurrentReveal(generation)) return; if (!isCurrentReveal(generation)) return;
showError( fail("That password is incorrect. Please try again.");
"export-privkey-password-error",
"That password is incorrect. Please try again.",
);
} finally { } finally {
btn.disabled = false; btn.disabled = false;
btn.classList.remove("text-muted"); btn.classList.remove("text-muted");
-1
View File
@@ -51,7 +51,6 @@ const VIEWS = [
"approve-site", "approve-site",
"approve-tx", "approve-tx",
"approve-sign", "approve-sign",
"approve-network",
"export-privkey", "export-privkey",
"show-phrase", "show-phrase",
// Shown by src/popup/views/stateRecovery.js when the stored profile // Shown by src/popup/views/stateRecovery.js when the stored profile
-4
View File
@@ -316,11 +316,7 @@ function init(ctx) {
const networkSelect = $("settings-network"); const networkSelect = $("settings-network");
networkSelect.addEventListener("change", async () => { networkSelect.addEventListener("change", async () => {
const newId = networkSelect.value; const newId = networkSelect.value;
if (newId === state.networkId) return;
const net = await onChainSwitch(newId); const net = await onChainSwitch(newId);
// Open pages are told by the background, as after a site's approved
// switch request.
notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId });
$("settings-rpc").value = state.rpcUrl; $("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl; $("settings-blockscout").value = state.blockscoutUrl;
showFlash("Switched to " + net.name + "."); showFlash("Switched to " + net.name + ".");
+13 -14
View File
@@ -21,8 +21,6 @@ const {
$, $,
showView, showView,
showFlash, showFlash,
showError,
hideError,
flashCopyFeedback, flashCopyFeedback,
goBack, goBack,
onViewLeave, onViewLeave,
@@ -54,6 +52,11 @@ function isCurrentReveal(generation) {
); );
} }
function fail(message) {
$("show-phrase-flash").textContent = message;
$("show-phrase-flash").style.visibility = "visible";
}
// Wipe every trace of the phrase and drop the wallet selection. Safe to // Wipe every trace of the phrase and drop the wallet selection. Safe to
// call when nothing was ever revealed, and safe to call twice. // call when nothing was ever revealed, and safe to call twice.
function clear() { function clear() {
@@ -63,7 +66,8 @@ function clear() {
$("show-phrase-password").value = ""; $("show-phrase-password").value = "";
$("show-phrase-result").classList.add("hidden"); $("show-phrase-result").classList.add("hidden");
$("show-phrase-password-section").classList.remove("hidden"); $("show-phrase-password-section").classList.remove("hidden");
hideError("show-phrase-password-error"); $("show-phrase-flash").textContent = "";
$("show-phrase-flash").style.visibility = "hidden";
} }
function show(walletIdx) { function show(walletIdx) {
@@ -86,23 +90,19 @@ function show(walletIdx) {
async function reveal() { async function reveal() {
const password = $("show-phrase-password").value; const password = $("show-phrase-password").value;
if (!password) { if (!password) {
showError("show-phrase-password-error", "Please enter your password."); fail("Please enter your password.");
return; return;
} }
if (walletIndex === null) { if (walletIndex === null) {
showError("show-phrase-password-error", "No wallet is selected."); fail("No wallet is selected.");
return; return;
} }
const wallet = state.wallets[walletIndex]; const wallet = state.wallets[walletIndex];
if (!walletHasRecoveryPhrase(wallet)) { if (!walletHasRecoveryPhrase(wallet)) {
showError( fail("This wallet does not have a recovery phrase.");
"show-phrase-password-error",
"This wallet does not have a recovery phrase.",
);
return; return;
} }
hideError("show-phrase-password-error");
const btn = $("btn-show-phrase-reveal"); const btn = $("btn-show-phrase-reveal");
btn.disabled = true; btn.disabled = true;
btn.classList.add("text-muted"); btn.classList.add("text-muted");
@@ -120,14 +120,13 @@ async function reveal() {
$("show-phrase-password-section").classList.add("hidden"); $("show-phrase-password-section").classList.add("hidden");
$("show-phrase-value").textContent = phrase; $("show-phrase-value").textContent = phrase;
$("show-phrase-result").classList.remove("hidden"); $("show-phrase-result").classList.remove("hidden");
$("show-phrase-flash").textContent = "";
$("show-phrase-flash").style.visibility = "hidden";
} catch { } catch {
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.
showError( fail("That password is incorrect. Please try again.");
"show-phrase-password-error",
"That password is incorrect. Please try again.",
);
} finally { } finally {
btn.disabled = false; btn.disabled = false;
btn.classList.remove("text-muted"); btn.classList.remove("text-muted");
+2 -3
View File
@@ -84,9 +84,8 @@ function show(tx) {
contractAddress: tx.contractAddress || null, contractAddress: tx.contractAddress || null,
// The network the history entry was read from. The type line and // The network the history entry was read from. The type line and
// the fee are in its native currency, not the active network's: // the fee are in its native currency, not the active network's:
// the active network can change, in Settings or when the user // a site can switch the active network before a later popup
// approves a site's request, before a later popup shows this // shows this screen again.
// screen again.
chainId: tx.chainId, chainId: tx.chainId,
}, },
}; };
+2 -3
View File
@@ -89,9 +89,8 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
// A native amount, here and on the success and error screens, is in the // A native amount, here and on the success and error screens, is in the
// native currency of txInfo.chainId, the network the transaction was sent // native currency of txInfo.chainId, the network the transaction was sent
// on, not the active network's: the active network can change, in // on, not the active network's: a site can switch the active network
// Settings or when the user approves a site's request, while this screen // while this screen is open or before a later popup resumes it.
// is open or before a later popup resumes it.
const symbol = const symbol =
txInfo.token === "ETH" txInfo.token === "ETH"
? nativeCurrencyByChainId(txInfo.chainId) ? nativeCurrencyByChainId(txInfo.chainId)
+12 -39
View File
@@ -1,14 +1,13 @@
// Preparation of the transaction an approval screen displays. // Preparation of the transaction an approval screen displays.
// //
// A dApp's eth_sendTransaction normally fixes `to`, `value` and `data`, and // A dApp's eth_sendTransaction normally fixes only `to`, `value` and `data`.
// sometimes the gas limit. The nonce, the fees, and the gas limit when the // The nonce, the gas limit and the fees have to be filled in from the network
// site gives none have to be filled in from the network before anything can // before anything can be signed, and whoever fills them in decides what the
// be signed, and whoever fills them in decides what the user is shown. That // user is shown. That work used to happen in the popup, after the user had
// work used to happen in the popup, after the user had already approved: the // already approved: the numbers on the approval screen came from the popup and
// numbers on the approval screen came from the popup and were compared against // were compared against nothing, so a compromised popup could display one fee
// nothing, so a compromised popup could display one fee and sign another, and // and sign another, and the ceilings in approvalVerify.js were all that stood
// the ceilings in approvalVerify.js were all that stood between the user and a // between the user and a fee that hands the validator the balance.
// fee that hands the validator the balance.
// //
// So it happens here instead, in the background, before the approval window is // So it happens here instead, in the background, before the approval window is
// opened. The background populates the transaction, shows that object, and // opened. The background populates the transaction, shows that object, and
@@ -32,7 +31,6 @@ const {
VoidSigner, VoidSigner,
accessListify, accessListify,
getAddress, getAddress,
getBigInt,
getBytes, getBytes,
hexlify, hexlify,
toQuantity, toQuantity,
@@ -49,11 +47,10 @@ const {
// minutes long, which is not a wait anyone will sit through. // minutes long, which is not a wait anyone will sit through.
const POPULATE_TIMEOUT_MS = 20000; const POPULATE_TIMEOUT_MS = 20000;
// The request fields taken from the page, plus `gas`, which requestFrom() // The request fields taken from the page. Anything else is dropped rather than
// takes as the gas limit. Anything else is dropped rather than passed to // passed to ethers: the object is page-controlled, and a future ethers that
// ethers: the object is page-controlled, and a future ethers that learns to // learns to carry a new transaction field must not start picking one up out of
// carry a new transaction field must not start picking one up out of it // it without this module knowing.
// without this module knowing.
// //
// The nonce is not taken from the page; it is always the account's next nonce // The nonce is not taken from the page; it is always the account's next nonce
// from the network. A page that chose it could replace one of the user's // from the network. A page that chose it could replace one of the user's
@@ -87,16 +84,6 @@ function present(v) {
return v !== null && v !== undefined && v !== ""; return v !== null && v !== undefined && v !== "";
} }
// Whether two quantities from the page are the same number. One that is not a
// number matches nothing.
function sameQuantity(a, b) {
try {
return getBigInt(a) === getBigInt(b);
} catch {
return false;
}
}
// These strings reach the user through the requesting page, so they are full // 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. // sentences even when the tail of one came from ethers or from the node.
function sentence(text) { function sentence(text) {
@@ -124,20 +111,6 @@ function requestFrom(txParams, from) {
for (const key of REQUEST_FIELDS) { for (const key of REQUEST_FIELDS) {
if (present(txParams[key])) request[key] = txParams[key]; if (present(txParams[key])) request[key] = txParams[key];
} }
// JSON-RPC calls the gas limit `gas`, and that is the name sites send.
// ethers only reads `gasLimit`: left as `gas` it would be dropped and an
// estimate signed in its place.
if (present(txParams.gas)) {
if (
present(request.gasLimit) &&
!sameQuantity(request.gasLimit, txParams.gas)
) {
throw fail(
"The site asked for two different gas limits for this transaction.",
);
}
request.gasLimit = txParams.gas;
}
if ( if (
present(request.type) && present(request.type) &&
!ALLOWED_TX_TYPES.includes(Number(request.type)) !ALLOWED_TX_TYPES.includes(Number(request.type))
-29
View File
@@ -53,7 +53,6 @@ const {
accessListify, accessListify,
formatEther, formatEther,
getAddress, getAddress,
getBigInt,
getBytes, getBytes,
toQuantity, toQuantity,
verifyMessage, verifyMessage,
@@ -636,33 +635,6 @@ function verifySignature(signParams, signature, expectedFrom) {
return recovered; return recovered;
} }
// Why typed data cannot be signed while `network` is the active network, as a
// sentence, or null when it can. EIP-712 has a wallet refuse typed data whose
// domain names another chain, because the signature is valid on that chain.
// ethers signs no chain id when the domain's is null or absent, and reads any
// other with getBigInt(), so 1, "1" and "0x1" are the same chain here too.
function typedDataChainRefusal(domain, network) {
const chainId = domain?.chainId;
if (chainId === undefined || chainId === null) return null;
let named;
try {
named = getBigInt(chainId);
} catch {
return "This typed data cannot be read, so it cannot be signed.";
}
const active = getBigInt(network.chainId);
if (named === active) return null;
return (
"This typed data is for chain id " +
named +
", but the active network is " +
network.name +
" (chain id " +
active +
"), so it cannot be signed."
);
}
// The stage a transaction approval failed at. Which stage it is decides // The stage a transaction approval failed at. Which stage it is decides
// whether the approval survives the failure. // whether the approval survives the failure.
const TX_STAGE_SIGN = "sign"; const TX_STAGE_SIGN = "sign";
@@ -824,7 +796,6 @@ function describeSigningFailure(response, fallbackMessage) {
module.exports = { module.exports = {
verifySignedTx, verifySignedTx,
verifySignature, verifySignature,
typedDataChainRefusal,
assertNoForbiddenFields, assertNoForbiddenFields,
assertNothingUnchecked, assertNothingUnchecked,
assertCanonicalBytes, assertCanonicalBytes,
+1 -4
View File
@@ -110,14 +110,11 @@ function hashListed(hash) {
* Generate hostname variants for subdomain matching. * Generate hostname variants for subdomain matching.
* "sub.evil.com" yields ["sub.evil.com", "evil.com"]. * "sub.evil.com" yields ["sub.evil.com", "evil.com"].
* *
* A trailing dot is dropped: "evil.com." is the fully qualified spelling of
* "evil.com", and the browser loads the same site from either.
*
* @param {string} hostname * @param {string} hostname
* @returns {string[]} * @returns {string[]}
*/ */
function hostnameVariants(hostname) { function hostnameVariants(hostname) {
const h = hostname.toLowerCase().replace(/\.$/, ""); const h = hostname.toLowerCase();
const variants = [h]; const variants = [h];
const parts = h.split("."); const parts = h.split(".");
// Parent domains: a.b.c.d -> b.c.d, c.d // Parent domains: a.b.c.d -> b.c.d, c.d
+5 -7
View File
@@ -36,13 +36,11 @@ const DEFECTS = {
"derivation path beneath that key instead of from a master " + "derivation path beneath that key instead of from a master " +
"key, so the addresses listed here are not the ones that key " + "key, so the addresses listed here are not the ones that key " +
"produces under the standard path.", "produces under the standard path.",
"Signing, sending and private key export are disabled for this " + "Signing and sending are disabled for this wallet. The addresses " +
"wallet. Anything these addresses hold can be reached outside " + "do descend from the extended private key you imported, so " +
"this wallet with the extended private key you imported, if " + "anything they hold is still reachable by software that " +
"you kept a copy of it, or with the master extended private " + "repeats the same non-standard derivation. Check them in a " +
"key or recovery phrase it came from, in software that " + "block explorer before deciding what to do.",
"repeats the same non-standard derivation. Check the " +
"addresses in a block explorer before deciding what to do.",
"To see the addresses this key produces under the standard path, " + "To see the addresses this key produces under the standard path, " +
"import the master extended private key, or the recovery " + "import the master extended private key, or the recovery " +
"phrase it came from, as a new wallet. Nothing here has been " + "phrase it came from, as a new wallet. Nothing here has been " +
-2
View File
@@ -39,8 +39,6 @@ jest.doMock("../src/popup/views/helpers", () => ({
$: element, $: element,
showView: () => {}, showView: () => {},
showFlash: () => {}, showFlash: () => {},
showError: () => {},
hideError: () => {},
goBack: () => {}, goBack: () => {},
clearViewStack: () => {}, clearViewStack: () => {},
onViewLeave: () => {}, onViewLeave: () => {},
-19
View File
@@ -1,19 +0,0 @@
// The token names and URLs in src/shared/tokenList.js are written from
// script/lib/token-enrichment.json by script/lib/apply-enrichment.js
// (`make apply-enrichment`). The committed list must be exactly what it writes
// from the committed data, so the list, the data and the script cannot drift
// apart.
const fs = require("fs");
const path = require("path");
const enrichment = require("../script/lib/token-enrichment.json");
const { applyEnrichment } = require("../script/lib/apply-enrichment");
test("src/shared/tokenList.js is what make apply-enrichment writes from the committed data", () => {
const committed = fs.readFileSync(
path.join(__dirname, "..", "src/shared/tokenList.js"),
"utf8",
);
expect(applyEnrichment(committed, enrichment)).toBe(committed);
});
+2 -24
View File
@@ -1,5 +1,5 @@
// The connection, transaction, signature and network switch prompts name the // The connection, transaction and signature prompts name the site by its full
// site by its full origin, scheme and port included, not by its bare hostname // origin, scheme and port included, not by its bare hostname
// (https://git.eeqj.de/sneak/AutistMask/issues/402). A page served over http, // (https://git.eeqj.de/sneak/AutistMask/issues/402). A page served over http,
// or on another port, of a host the user trusts over https must not raise a // or on another port, of a host the user trusts over https must not raise a
// prompt that reads as that trusted site. // prompt that reads as that trusted site.
@@ -104,7 +104,6 @@ beforeEach(() => {
test("the connection prompt shows the origin", async () => { test("the connection prompt shows the origin", async () => {
await openApproval({}); await openApproval({});
expect(node("approve-origin").textContent).toBe(ORIGIN); expect(node("approve-origin").textContent).toBe(ORIGIN);
expect(node("view-approve-site").classList.contains("hidden")).toBe(false);
}); });
test("the transaction prompt shows the origin", async () => { test("the transaction prompt shows the origin", async () => {
@@ -139,24 +138,3 @@ test("the signature prompt shows the origin", async () => {
}); });
expect(node("approve-sign-origin").textContent).toBe(ORIGIN); expect(node("approve-sign-origin").textContent).toBe(ORIGIN);
}); });
test("the network switch prompt shows the origin and both networks", async () => {
await openApproval({
type: "network",
currentNetworkId: "mainnet",
requestedNetworkId: "sepolia",
});
expect(node("approve-network-origin").textContent).toBe(ORIGIN);
expect(node("approve-network-current").textContent).toBe(
"Ethereum Mainnet",
);
expect(node("approve-network-requested").textContent).toBe(
"Sepolia Testnet",
);
// Its own screen, and not the connection prompt, whose "Allow" answers
// on the same port.
expect(node("view-approve-network").classList.contains("hidden")).toBe(
false,
);
expect(node("view-approve-site").classList.contains("hidden")).toBe(true);
});
+1 -47
View File
@@ -9,7 +9,7 @@
// requesting page or the RPC node can say turns it into an approval that // requesting page or the RPC node can say turns it into an approval that
// should never have been raised. // should never have been raised.
const { Network, Transaction, Wallet, toQuantity } = require("ethers"); const { Network, Wallet } = require("ethers");
const { const {
prepareApprovalTx, prepareApprovalTx,
serializeApprovedTx, serializeApprovedTx,
@@ -138,43 +138,6 @@ describe("prepareApprovalTx", () => {
expect(approved.maxFeePerGas).toBe("0x12a05f200"); expect(approved.maxFeePerGas).toBe("0x12a05f200");
}); });
// `gas` is the JSON-RPC name for the gas limit and the one sites send.
// The node would estimate 21000; the site's 200000 is what is shown and
// what is signed.
test("uses the gas limit a site sends as gas", async () => {
const approved = await prepareApprovalTx(
providerWith(),
signer.address,
{ ...TX_PARAMS, gas: "0x30d40" },
);
expect(approved.gasLimit).toBe("0x30d40");
const signed = Transaction.from(
await signer.signTransaction({ ...approved }),
);
expect(signed.gasLimit).toBe(200000n);
});
test("accepts gas and gasLimit that name the same number", async () => {
const approved = await prepareApprovalTx(
providerWith(),
signer.address,
{ ...TX_PARAMS, gas: "0x30d40", gasLimit: 200000 },
);
expect(approved.gasLimit).toBe("0x30d40");
});
test("refuses gas and gasLimit that disagree", async () => {
await expect(
prepareApprovalTx(providerWith(), signer.address, {
...TX_PARAMS,
gas: "0x30d40",
gasLimit: "0x5208",
}),
).rejects.toThrow(
"The site asked for two different gas limits for this transaction.",
);
});
test("carries an access list the request asked for", async () => { test("carries an access list the request asked for", async () => {
const approved = await prepareApprovalTx( const approved = await prepareApprovalTx(
providerWith(), providerWith(),
@@ -249,15 +212,6 @@ describe("prepareApprovalTx", () => {
).rejects.toThrow(/gas limit no network this wallet supports/); ).rejects.toThrow(/gas limit no network this wallet supports/);
}); });
test("refuses a gas limit the site sent above the ceiling", async () => {
await expect(
prepareApprovalTx(providerWith(), signer.address, {
...TX_PARAMS,
gas: toQuantity(MAX_GAS_LIMIT + 1n),
}),
).rejects.toThrow(/gas limit no network this wallet supports/);
});
// The combined bound at population: a gas limit and a fee that are each // The combined bound at population: a gas limit and a fee that are each
// under their own ceiling but multiply to thousands of ETH is refused // under their own ceiling but multiply to thousands of ETH is refused
// before the approval window opens, so the user is never shown a // before the approval window opens, so the user is never shown a
+1
View File
@@ -58,6 +58,7 @@ const TX_PARAMS = {
to: RECIPIENT, to: RECIPIENT,
value: "0x2386f26fc10000", value: "0x2386f26fc10000",
data: "0xdeadbeef", data: "0xdeadbeef",
gas: "0x5208",
}; };
// The values populateTransaction() fills in when the dApp fixed none of them. // The values populateTransaction() fills in when the dApp fixed none of them.
-47
View File
@@ -1,47 +0,0 @@
// The approval window shows no screen until the background has described the
// approval it answers (https://git.eeqj.de/sneak/AutistMask/issues/408). The
// connection prompt's "Allow" and the network switch prompt's "Switch" answer
// on the same port, so a window that showed the connection prompt while it
// waited could approve a network switch.
//
// Booted through the real popup entry point, which is where the connection
// prompt used to be put up before the background had answered.
const {
bootPopup,
cleanupPopup,
settle,
unversionedValidProfile,
} = require("./support/popupBoot");
afterEach(cleanupPopup);
test("the approval window shows no screen until the background describes the approval", async () => {
// The background's answer, held until the test gives it.
let answer = null;
const env = await bootPopup(unversionedValidProfile(), {
search: "?approval=approval-1",
runtime: {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
if (msg.type === "AUTISTMASK_GET_APPROVAL") answer = reply;
},
},
});
expect(answer).not.toBeNull();
// No screen at all, the connection prompt included.
expect(env.visibleViews()).toEqual([]);
answer({
type: "network",
origin: "https://dapp.example",
currentNetworkId: "mainnet",
requestedNetworkId: "sepolia",
isPhishingDomain: false,
});
await settle();
expect(env.visibleViews()).toEqual(["approve-network"]);
expect(env.pageErrors).toEqual([]);
});
+1 -56
View File
@@ -397,13 +397,7 @@ function loadBackground(options) {
rpcResult = r; rpcResult = r;
}, },
); );
return { return { result: () => rpcResult };
id: () =>
new URL(created[created.length - 1].url).searchParams.get(
"approval",
),
result: () => rpcResult,
};
} }
// A dApp asking to connect. The origin defaults to one the persisted // A dApp asking to connect. The origin defaults to one the persisted
@@ -1490,55 +1484,6 @@ describe("what the approval is verified against", () => {
}); });
}); });
// Typed data whose domain names another chain is refused on the sign
// screen; a signature for it that reaches the background anyway is not
// handed to the page (https://git.eeqj.de/sneak/AutistMask/issues/519).
// TYPED_DATA names mainnet.
async function answerTypedData(bg, pending) {
const { domain, types, message } = JSON.parse(TYPED_DATA);
delete types.EIP712Domain;
const signature = await signer.signTypedData(domain, types, message);
const answer = bg.send(
{
type: "AUTISTMASK_SIGN_RESPONSE",
id: pending.id(),
approved: true,
signature,
},
{ url: bg.fromPopup.url },
);
await settle();
return { answer, signature };
}
test("typed data for the active chain is handed back signed", async () => {
const bg = loadBackground();
const pending = bg.requestTypedData();
await settle();
const { answer, signature } = await answerTypedData(bg, pending);
expect(answer.sendResponse).toHaveBeenCalledWith({ signature });
expect(pending.result()).toEqual({ result: signature });
});
test("typed data for another chain is refused, not handed back", async () => {
const bg = loadBackground();
bg.setNetwork(SEPOLIA);
const pending = bg.requestTypedData();
await settle();
const { answer } = await answerTypedData(bg, pending);
const refusal =
"This typed data is for chain id 1, but the active network is Sepolia Testnet (chain id 11155111), so it cannot be signed.";
expect(answer.sendResponse).toHaveBeenCalledWith({
error: refusal,
retryable: false,
});
expect(pending.result()).toEqual({ error: { message: refusal } });
});
test("a sign request from another address raises no approval at all", async () => { test("a sign request from another address raises no approval at all", async () => {
const bg = loadBackground(); const bg = loadBackground();
const pending = bg.requestSign(other.address); const pending = bg.requestSign(other.address);
+14 -14
View File
@@ -25,7 +25,6 @@
const { Wallet } = require("ethers"); const { Wallet } = require("ethers");
const { networkById } = require("../src/shared/networks"); const { networkById } = require("../src/shared/networks");
const { applyChainSwitchFields } = require("../src/shared/chainSwitchFields");
const { makeStorageStub } = require("./support/storageStub"); const { makeStorageStub } = require("./support/storageStub");
const SIGNER_KEY = const SIGNER_KEY =
@@ -241,8 +240,8 @@ describe("a chain switch under a transaction already committed to a chain", () =
// The artifact is verified against the chain read at the top of the // The artifact is verified against the chain read at the top of the
// attempt. Whatever endpoint it is then broadcast to has to be that same // attempt. Whatever endpoint it is then broadcast to has to be that same
// chain's — otherwise the wallet checks a transaction against Sepolia and // chain's — otherwise the wallet checks a transaction against Sepolia and
// sends it to a mainnet node. The user can switch the network in Settings // sends it to a mainnet node. A connected site can switch the chain at any
// at any moment, including this one. // moment, including this one.
test("the artifact is broadcast to the endpoint of the chain it was verified against", async () => { test("the artifact is broadcast to the endpoint of the chain it was verified against", async () => {
const bg = loadWorker("sepolia"); const bg = loadWorker("sepolia");
@@ -265,9 +264,9 @@ describe("a chain switch under a transaction already committed to a chain", () =
populated(Number(SEPOLIA.networkVersion)), populated(Number(SEPOLIA.networkVersion)),
); );
// The user switches the network in Settings while the attempt is // A connected site switches the chain while the attempt is running,
// running: the popup writes the switched record to storage in full // and the switch is committed to storage in full before the attempt
// before the attempt goes any further. // goes any further.
// //
// It is fired from inside the attempt's SECOND state read, because // It is fired from inside the attempt's SECOND state read, because
// that is where the window used to be: the chain id was captured at // that is where the window used to be: the chain id was captured at
@@ -278,18 +277,18 @@ describe("a chain switch under a transaction already committed to a chain", () =
// this to fire on, and the switch below runs after the attempt is // this to fire on, and the switch below runs after the attempt is
// done instead — which is the point. // done instead — which is the point.
let reads = 0; let reads = 0;
let switched = false; let switched = null;
const doSwitch = () => { const doSwitch = async () => {
const record = bg.persisted(); switched = bg.rpc("wallet_switchEthereumChain", [
applyChainSwitchFields(record, MAINNET.id); { chainId: MAINNET.chainId },
global.chrome.storage.write("autistmask", record); ]);
switched = true; await settle();
}; };
bg.setGetHook(async () => { bg.setGetHook(async () => {
reads++; reads++;
if (reads !== 2) return; if (reads !== 2) return;
bg.setGetHook(null); bg.setGetHook(null);
doSwitch(); await doSwitch();
}); });
const attempt = bg.send( const attempt = bg.send(
@@ -304,7 +303,8 @@ describe("a chain switch under a transaction already committed to a chain", () =
await settle(); await settle();
bg.setGetHook(null); bg.setGetHook(null);
if (!switched) doSwitch(); if (!switched) await doSwitch();
expect(switched.result()).toEqual({ result: null });
expect(bg.persisted().networkId).toBe("mainnet"); expect(bg.persisted().networkId).toBe("mainnet");
await settle(); await settle();
+38 -324
View File
@@ -1,24 +1,16 @@
// Who may move the active chain, and when. // Who may move the active chain.
// //
// wallet_switchEthereumChain used to be answered for any origin at all, with // wallet_switchEthereumChain used to be answered for any origin at all, with
// no connection check and no prompt, so a page the user had never connected // no connection check and no prompt, so a page the user had never connected
// to could clear the [TESTNET] banner under someone who believed they were // to could clear the [TESTNET] banner under someone who believed they were
// on Sepolia (https://git.eeqj.de/sneak/AutistMask/issues/308). Gated on the // on Sepolia (https://git.eeqj.de/sneak/AutistMask/issues/308). The refusal
// connection, a connected site could still move the wallet between mainnet and // is asserted as a refusal to ACT — the state unmoved and no chainChanged
// Sepolia without asking. Only the user switches the network: a connected // broadcast — because an error code alone would not distinguish a gate from
// site's request opens a prompt, and nothing changes unless the user approves // a switch that happened and then reported a failure.
// it there (https://git.eeqj.de/sneak/AutistMask/issues/408).
// //
// Every refusal is asserted as a refusal to ACT — the stored record unmoved // The endpoint half of that issue lives in tests/networkEndpoints.test.js,
// and no chainChanged broadcast — because an error code alone would not // which covers the popup's chain switch; this file covers the background's,
// distinguish a refusal from a switch that happened and then reported a // which goes through storage rather than the shared state singleton.
// failure.
//
// The endpoint half of #308 lives in tests/networkEndpoints.test.js, which
// covers the popup's chain switch; this file covers the background's, which
// goes through storage rather than the shared state singleton. The last block
// covers what the background tells open tabs when the user switches the
// network in Settings.
const { networkById } = require("../src/shared/networks"); const { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub"); const { makeStorageStub } = require("./support/storageStub");
@@ -29,23 +21,18 @@ const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const CONNECTED_ORIGIN = "https://dapp.example"; const CONNECTED_ORIGIN = "https://dapp.example";
const STRANGER_ORIGIN = "https://stranger.example"; const STRANGER_ORIGIN = "https://stranger.example";
const EXT_URL = "chrome-extension://autistmask/";
const MAINNET = networkById("mainnet"); const MAINNET = networkById("mainnet");
const SEPOLIA = networkById("sepolia"); const SEPOLIA = networkById("sepolia");
// The user's own node, so a switch that happens is visible as the loss of it. // The user's own node, so a switch that happens is visible as the loss of it.
const CUSTOM_RPC = "http://127.0.0.1:8545"; const CUSTOM_RPC = "http://127.0.0.1:8545";
// A balance a switch would clear, so a switch that happens is visible here too.
function walletFixture() { function walletFixture() {
return [ return [
{ {
name: "Wallet 1", name: "Wallet 1",
type: "hd", type: "hd",
addresses: [ addresses: [{ address: ADDRESS, balance: "0", tokenBalances: [] }],
{ address: ADDRESS, balance: "1.5", tokenBalances: [] },
],
}, },
]; ];
} }
@@ -60,6 +47,10 @@ afterEach(() => {
delete global.chrome; delete global.chrome;
}); });
// ---------------------------------------------------------------------------
// The gate: which origins the background will switch the chain for.
// ---------------------------------------------------------------------------
// Load the background worker against stubbed browser APIs, with the real // Load the background worker against stubbed browser APIs, with the real
// chain-switch and persistence modules behind it, and return the handles to // chain-switch and persistence modules behind it, and return the handles to
// drive it. // drive it.
@@ -100,46 +91,30 @@ function loadBackground() {
const storage = makeStorageStub({ autistmask: persisted }); const storage = makeStorageStub({ autistmask: persisted });
let messageListener = null; let messageListener = null;
let connectListener = null;
let windowRemovedListener = null;
// The URL of every approval window the background opened. The approval id
// is in it, and that is how the popup learns which approval it answers.
const opened = [];
// Every message the background pushed at a content script. chainChanged // Every message the background pushed at a content script. chainChanged
// is what tells a page the wallet moved, so a switch is visible here as // is what tells a page the wallet moved, so an ungated switch is visible
// well as in the state. // here as well as in the state.
const toTabs = []; const toTabs = [];
global.chrome = { global.chrome = {
storage, storage,
runtime: { runtime: {
getURL: (path) => EXT_URL + path, getURL: (path) => "chrome-extension://autistmask/" + path,
onMessage: { onMessage: {
addListener: (fn) => { addListener: (fn) => {
messageListener = fn; messageListener = fn;
}, },
}, },
onConnect: { onConnect: { addListener: () => {} },
addListener: (fn) => {
connectListener = fn;
},
},
lastError: null, lastError: null,
}, },
windows: { windows: {
getLastFocused: (cb) => cb(null), getLastFocused: (cb) => cb(null),
create: (options, cb) => { create: (options, cb) => cb({ id: 1 }),
opened.push(options.url);
cb({ id: opened.length });
},
remove: (id, cb) => { remove: (id, cb) => {
if (cb) cb(); if (cb) cb();
}, },
onRemoved: { onRemoved: { addListener: () => {} },
addListener: (fn) => {
windowRemovedListener = fn;
},
},
}, },
tabs: { tabs: {
query: (queryInfo, cb) => cb([{ id: 1 }]), query: (queryInfo, cb) => cb([{ id: 1 }]),
@@ -153,8 +128,6 @@ function loadBackground() {
require("../src/background/index"); require("../src/background/index");
// A page's request. Its answer is read with result(), which is null for as
// long as the request is waiting on the user.
async function switchChain(chainId, origin) { async function switchChain(chainId, origin) {
let result = null; let result = null;
messageListener( messageListener(
@@ -169,155 +142,56 @@ function loadBackground() {
}, },
); );
await settle(); await settle();
return { result: () => result }; return result;
}
function promptId() {
return new URL(opened[opened.length - 1]).searchParams.get("approval");
}
// What the popup is told to show for the prompt.
function describePrompt() {
let reply = null;
messageListener(
{ type: "AUTISTMASK_GET_APPROVAL", id: promptId() },
{ url: EXT_URL + "src/popup/index.html" },
(r) => {
reply = r;
},
);
return reply;
}
// The user's answer, as the popup sends it: on the port named for the
// approval, from the extension's own page, and then the window closes.
async function answerPrompt(approved) {
const onMessage = [];
const onDisconnect = [];
const port = {
name: "approval:" + promptId(),
sender: { url: EXT_URL + "src/popup/index.html" },
onMessage: { addListener: (fn) => onMessage.push(fn) },
onDisconnect: { addListener: (fn) => onDisconnect.push(fn) },
};
connectListener(port);
for (const fn of onMessage) {
fn(
{
type: "AUTISTMASK_APPROVAL_DECISION",
approved,
remember: false,
},
port,
);
}
for (const fn of onDisconnect) fn(port);
await settle();
}
// The user closes the prompt window without answering it.
async function closePrompt() {
windowRemovedListener(opened.length);
await settle();
} }
return { return {
switchChain, switchChain,
describePrompt,
answerPrompt,
closePrompt,
opened,
// A message to the background from `sender`, and its answer.
send: (msg, sender) => {
let reply = null;
messageListener(msg, sender, (r) => {
reply = r;
});
return reply;
},
walletState: () => storage.read("autistmask"), walletState: () => storage.read("autistmask"),
chainChangedEvents: () => chainChangedEvents: () =>
toTabs.filter((m) => m.eventName === "chainChanged"), toTabs.filter((m) => m.eventName === "chainChanged"),
}; };
} }
const USER_REJECTED = { code: 4001, message: "User rejected the request." };
describe("wallet_switchEthereumChain is gated on the connection", () => { describe("wallet_switchEthereumChain is gated on the connection", () => {
test("an origin the wallet was never connected to is refused with 4100", async () => { test("an origin the wallet was never connected to is refused with 4100", async () => {
const bg = loadBackground(); const bg = loadBackground();
const before = bg.walletState();
const request = await bg.switchChain(SEPOLIA.chainId, STRANGER_ORIGIN); const result = await bg.switchChain(SEPOLIA.chainId, STRANGER_ORIGIN);
expect(request.result().error).toEqual({ expect(result.error).toEqual({ code: 4100, message: "Unauthorized" });
code: 4100, expect(result.result).toBeUndefined();
message: "Unauthorized",
});
expect(request.result().result).toBeUndefined();
// The refusal has to be a refusal to ACT, not just an error string: // The refusal has to be a refusal to ACT, not just an error string:
// the wallet is still on mainnet, still on the user's own node, and // the wallet is still on mainnet, still on the user's own node, and
// no page was told the chain moved. Nor was the user asked. // no page was told the chain moved.
expect(bg.walletState()).toEqual(before); expect(bg.walletState().networkId).toBe("mainnet");
expect(bg.walletState().rpcUrl).toBe(CUSTOM_RPC);
expect(bg.chainChangedEvents()).toEqual([]); expect(bg.chainChangedEvents()).toEqual([]);
expect(bg.opened).toEqual([]);
}); });
test("an unconnected origin is refused even for the chain already active", async () => { test("an unconnected origin is refused even for the chain already active", async () => {
const bg = loadBackground(); const bg = loadBackground();
const request = await bg.switchChain(MAINNET.chainId, STRANGER_ORIGIN); const result = await bg.switchChain(MAINNET.chainId, STRANGER_ORIGIN);
expect(request.result().error).toEqual({ expect(result.error).toEqual({ code: 4100, message: "Unauthorized" });
code: 4100,
message: "Unauthorized",
});
}); });
test("an unconnected origin is refused before the unsupported-chain answer", async () => { test("an unconnected origin is refused before the unsupported-chain answer", async () => {
const bg = loadBackground(); const bg = loadBackground();
const request = await bg.switchChain("0x89", STRANGER_ORIGIN); const result = await bg.switchChain("0x89", STRANGER_ORIGIN);
expect(request.result().error.code).toBe(4100); expect(result.error.code).toBe(4100);
});
});
describe("only the user switches the network", () => {
test("a connected site's request changes nothing while the prompt is open", async () => {
const bg = loadBackground();
const before = bg.walletState();
const request = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
// Waiting on the user, with one prompt on screen naming the site and
// both networks.
expect(request.result()).toBeNull();
expect(bg.opened).toHaveLength(1);
expect(bg.describePrompt()).toMatchObject({
origin: CONNECTED_ORIGIN,
type: "network",
currentNetworkId: "mainnet",
requestedNetworkId: "sepolia",
});
// The network, the endpoints and the balances are as they were, and
// no page was told otherwise.
expect(bg.walletState()).toEqual(before);
expect(bg.chainChangedEvents()).toEqual([]);
}); });
test("approving the prompt switches the network", async () => { test("a connected origin switches the chain", async () => {
const bg = loadBackground(); const bg = loadBackground();
const request = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN); const result = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
await bg.answerPrompt(true);
expect(request.result()).toEqual({ result: null }); expect(result).toEqual({ result: null });
const after = bg.walletState(); expect(bg.walletState().networkId).toBe("sepolia");
expect(after.networkId).toBe("sepolia");
expect(after.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
expect(after.wallets[0].addresses[0].balance).toBe("0");
expect(bg.chainChangedEvents()).toEqual([ expect(bg.chainChangedEvents()).toEqual([
{ {
type: "AUTISTMASK_EVENT", type: "AUTISTMASK_EVENT",
@@ -327,182 +201,22 @@ describe("only the user switches the network", () => {
]); ]);
}); });
test("rejecting the prompt changes nothing and answers 4001", async () => { test("a connected origin asking for an unsupported chain still gets 4902", async () => {
const bg = loadBackground();
const before = bg.walletState();
const request = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
await bg.answerPrompt(false);
expect(request.result()).toEqual({ error: USER_REJECTED });
expect(bg.walletState()).toEqual(before);
expect(bg.chainChangedEvents()).toEqual([]);
});
test("closing the prompt without answering changes nothing and answers 4001", async () => {
const bg = loadBackground();
const before = bg.walletState();
const request = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
await bg.closePrompt();
expect(request.result()).toEqual({ error: USER_REJECTED });
expect(bg.walletState()).toEqual(before);
expect(bg.chainChangedEvents()).toEqual([]);
});
test("a request for the chain already active opens no prompt", async () => {
const bg = loadBackground();
const before = bg.walletState();
const request = await bg.switchChain(MAINNET.chainId, CONNECTED_ORIGIN);
expect(request.result()).toEqual({ result: null });
expect(bg.opened).toEqual([]);
expect(bg.walletState()).toEqual(before);
expect(bg.chainChangedEvents()).toEqual([]);
});
test("a second request while the prompt is open is refused with -32002", async () => {
const bg = loadBackground(); const bg = loadBackground();
const first = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN); const result = await bg.switchChain("0x89", CONNECTED_ORIGIN);
const second = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
expect(second.result().error.code).toBe(-32002); expect(result.error.code).toBe(4902);
expect(bg.opened).toHaveLength(1);
// The first prompt still decides.
await bg.answerPrompt(true);
expect(first.result()).toEqual({ result: null });
expect(bg.walletState().networkId).toBe("sepolia");
});
test("a request for an unsupported chain still gets 4902 and no prompt", async () => {
const bg = loadBackground();
const request = await bg.switchChain("0x89", CONNECTED_ORIGIN);
expect(request.result().error.code).toBe(4902);
expect(bg.opened).toEqual([]);
expect(bg.walletState().networkId).toBe("mainnet"); expect(bg.walletState().networkId).toBe("mainnet");
}); });
test("an approved switch keeps the user's endpoint", async () => { test("a switch by a connected origin keeps the user's endpoint", async () => {
const bg = loadBackground(); const bg = loadBackground();
await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN); await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
await bg.answerPrompt(true);
expect(bg.walletState().rpcUrl).toBe(SEPOLIA.defaultRpcUrl); expect(bg.walletState().rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
await bg.switchChain(MAINNET.chainId, CONNECTED_ORIGIN); await bg.switchChain(MAINNET.chainId, CONNECTED_ORIGIN);
await bg.answerPrompt(true);
expect(bg.walletState().rpcUrl).toBe(CUSTOM_RPC); expect(bg.walletState().rpcUrl).toBe(CUSTOM_RPC);
}); });
}); });
// Switching the network in Settings tells open pages, as an approved site
// request does (https://git.eeqj.de/sneak/AutistMask/issues/500). These drive
// the real Settings view over the background's storage, with what it sends
// delivered to the background from the extension's own page.
describe("switching the network in Settings tells every open tab", () => {
const POPUP = { url: EXT_URL + "src/popup/index.html" };
// 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: "",
style: {},
dataset: {},
classList: { add() {}, remove() {} },
listeners: {},
addEventListener(event, handler) {
this.listeners[event] = handler;
},
querySelectorAll: () => [],
};
}
// Settings, opened the way the popup opens it. Returns its network
// selector.
async function openSettings(bg) {
const elements = {};
const element = (id) => (elements[id] ||= fakeElement());
jest.doMock("../src/popup/views/helpers", () => ({
$: element,
showView: () => {},
updateDebugBanner: () => {},
showFlash: () => {},
escapeHtml: (s) => s,
flashCopyFeedback: () => {},
goBack: () => {},
pushCurrentView: () => {},
onViewLeave: () => {},
VIEWS: [],
}));
global.chrome.runtime.sendMessage = (msg) => {
bg.send(msg, POPUP);
};
await require("../src/shared/state").loadState();
require("../src/popup/views/settings").init({
pageClosed: new AbortController().signal,
});
const select = element("settings-network");
select.value = "mainnet";
return select;
}
// The user picks `networkId` in the selector.
async function choose(select, networkId) {
select.value = networkId;
await select.listeners.change();
await settle();
}
afterEach(() => {
jest.dontMock("../src/popup/views/helpers");
});
test("switching to the other network sends chainChanged once, with its chain id", async () => {
const bg = loadBackground();
const select = await openSettings(bg);
await choose(select, "sepolia");
expect(bg.walletState().networkId).toBe("sepolia");
expect(bg.chainChangedEvents()).toEqual([
{
type: "AUTISTMASK_EVENT",
eventName: "chainChanged",
data: SEPOLIA.chainId,
},
]);
});
test("choosing the network already active changes nothing and sends nothing", async () => {
const bg = loadBackground();
const select = await openSettings(bg);
const before = bg.walletState();
await choose(select, "mainnet");
expect(bg.walletState()).toEqual(before);
expect(bg.chainChangedEvents()).toEqual([]);
});
test("a page cannot make the background send chainChanged", async () => {
const bg = loadBackground();
const reply = bg.send(
{ type: "AUTISTMASK_NETWORK_CHANGED", chainId: SEPOLIA.chainId },
{ url: CONNECTED_ORIGIN + "/" },
);
await settle();
expect(reply).toEqual({ error: "Unauthorized sender" });
expect(bg.chainChangedEvents()).toEqual([]);
});
});
+2 -44
View File
@@ -14,10 +14,6 @@
// itself: the handler has to do it. tests/chainSwitchGate.test.js mocks the // itself: the handler has to do it. tests/chainSwitchGate.test.js mocks the
// state module wholesale and tests/networkEndpoints.test.js always loads // state module wholesale and tests/networkEndpoints.test.js always loads
// first, so neither can see this. // first, so neither can see this.
//
// A site's switch happens only once the user approves it on a prompt
// (https://git.eeqj.de/sneak/AutistMask/issues/408), so every switch here is
// approved the way the popup approves one.
const { networkById } = require("../src/shared/networks"); const { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub"); const { makeStorageStub } = require("./support/storageStub");
@@ -94,9 +90,6 @@ function loadColdWorker(networkId) {
const storage = makeStorageStub({ autistmask: storedProfile(networkId) }); const storage = makeStorageStub({ autistmask: storedProfile(networkId) });
let messageListener = null; let messageListener = null;
let connectListener = null;
// The URL of every approval window opened; the approval id is in it.
const opened = [];
const toTabs = []; const toTabs = [];
global.chrome = { global.chrome = {
@@ -108,19 +101,12 @@ function loadColdWorker(networkId) {
messageListener = fn; messageListener = fn;
}, },
}, },
onConnect: { onConnect: { addListener: () => {} },
addListener: (fn) => {
connectListener = fn;
},
},
lastError: null, lastError: null,
}, },
windows: { windows: {
getLastFocused: (cb) => cb(null), getLastFocused: (cb) => cb(null),
create: (options, cb) => { create: (options, cb) => cb({ id: 1 }),
opened.push(options.url);
cb({ id: opened.length });
},
remove: (id, cb) => { remove: (id, cb) => {
if (cb) cb(); if (cb) cb();
}, },
@@ -138,32 +124,6 @@ function loadColdWorker(networkId) {
require("../src/background/index"); require("../src/background/index");
// The user approves the prompt the request opened, as the popup does: a
// decision on the port named for the approval, from the extension's own
// page.
function approvePrompt() {
const id = new URL(opened[opened.length - 1]).searchParams.get(
"approval",
);
let onDecision = null;
const port = {
name: "approval:" + id,
sender: { url: "chrome-extension://autistmask/src/popup/" },
onMessage: {
addListener: (fn) => {
onDecision = fn;
},
},
onDisconnect: { addListener: () => {} },
};
connectListener(port);
onDecision(
{ type: "AUTISTMASK_APPROVAL_DECISION", approved: true },
port,
);
}
// A connected site asks for `chainId`, and the user approves it.
async function switchChain(chainId) { async function switchChain(chainId) {
let result = null; let result = null;
messageListener( messageListener(
@@ -178,8 +138,6 @@ function loadColdWorker(networkId) {
}, },
); );
await settle(); await settle();
approvePrompt();
await settle();
return result; return result;
} }
+1 -1
View File
@@ -253,7 +253,7 @@ describe("reaching the screen", () => {
const { decryptWithPassword } = require("../src/shared/vault"); const { decryptWithPassword } = require("../src/shared/vault");
decryptWithPassword.mockRejectedValue(new Error("nope")); decryptWithPassword.mockRejectedValue(new Error("nope"));
await click("btn-delete-wallet-confirm"); await click("btn-delete-wallet-confirm");
expect(node("delete-wallet-password-error").textContent).toBe( expect(node("delete-wallet-flash").textContent).toBe(
"That password is incorrect. Please try again.", "That password is incorrect. Please try again.",
); );
}); });
+2 -8
View File
@@ -279,18 +279,12 @@ class Driver {
// Shown means shown: in the popup a view is switched by toggling a // Shown means shown: in the popup a view is switched by toggling a
// "hidden" class, and an element that is present but collapsed is not // "hidden" class, and an element that is present but collapsed is not
// the thing a test means by visible. Until the page's stylesheet has // the thing a test means by visible.
// applied that class hides nothing and every view lays out, so the page
// must also have finished loading, which it does only after its
// stylesheet, and neither the element nor anything enclosing it may
// carry the class (https://git.eeqj.de/sneak/AutistMask/issues/502).
async waitVisible(selector, timeout = DEFAULT_WAIT_MS) { async waitVisible(selector, timeout = DEFAULT_WAIT_MS) {
return this.waitFor( return this.waitFor(
"selector " + selector + " to be visible", "selector " + selector + " to be visible",
`const el = document.querySelector(arguments[0]); `const el = document.querySelector(arguments[0]);
if (document.readyState !== "complete" || !el) return false; if (!el) return false;
if (el.closest(".hidden")) return false;
if (getComputedStyle(el).visibility !== "visible") return false;
const r = el.getBoundingClientRect(); const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;`, return r.width > 0 && r.height > 0;`,
[selector], [selector],
-166
View File
@@ -63,7 +63,6 @@ const {
const { ConsoleErrors, EXTENSION_ORIGIN, start, sleep } = require("./driver"); const { ConsoleErrors, EXTENSION_ORIGIN, start, sleep } = require("./driver");
const { startDappServer } = require("./dapp"); const { startDappServer } = require("./dapp");
const { STUB_COUNTERPARTY } = require("../network"); const { STUB_COUNTERPARTY } = require("../network");
const { NETWORKS } = require("../../../src/shared/networks");
const { const {
STATE_SCHEMA_VERSION, STATE_SCHEMA_VERSION,
stateProblem, stateProblem,
@@ -128,18 +127,6 @@ step("the popup is drawn in the monospace font it declares", async (env) => {
); );
}); });
// The same check as the Chrome suite's (#456), so both browsers are held to
// the same size.
step("the popup's text is drawn at the 14px text-sm declares", async (env) => {
const size = await env.driver.execute(
"return getComputedStyle(document.body).fontSize;",
);
assert(
size === "14px",
"the popup's text is drawn at " + size + ", not at the 14px of text-sm",
);
});
// The recovery screen (#361): the Chrome suite's four cases, run before any // The recovery screen (#361): the Chrome suite's four cases, run before any
// wallet exists for the same reason. With no wallet nothing saves on a timer, // wallet exists for the same reason. With no wallet nothing saves on a timer,
// so no save can write a good record over the unreadable one. The last of them // so no save can write a good record over the unreadable one. The last of them
@@ -697,159 +684,6 @@ step(
}, },
); );
// The network fields of the stored record, read on the popup page, the one
// moz-extension:// document the suite has open.
async function storedNetwork(env) {
const d = env.driver;
await d.switchToWindow(env.popupWindow);
const stored = await d.executeAsync(
`const done = arguments[arguments.length - 1];
const api = typeof browser !== "undefined" ? browser : chrome;
Promise.resolve(api.storage.local.get("autistmask")).then(
(r) => done({
networkId: r.autistmask.networkId,
rpcUrl: r.autistmask.rpcUrl,
blockscoutUrl: r.autistmask.blockscoutUrl,
}),
(e) => done({ error: String((e && e.message) || e) }),
);`,
);
assert(
stored && !stored.error,
"could not read the stored network: " + (stored && stored.error),
);
return stored;
}
// Every chainChanged event the test page has been sent, waiting up to
// `timeout` for there to be `count` of them: the background sends the event
// alongside its answer to the request, so it can arrive just after it. Leaves
// the driver on the page.
async function chainChangedEvents(env, count = 0, timeout = 5000) {
const d = env.driver;
await d.switchToWindow(env.dappWindow);
const deadline = Date.now() + timeout;
for (;;) {
const events = (await dappMessages(d, "AUTISTMASK_EVENT")).filter(
(m) => m.eventName === "chainChanged",
);
if (events.length >= count || Date.now() > deadline) return events;
await sleep(100);
}
}
// Ask, from the page, to switch from network `from` to network `to`, and
// return the prompt that opens, checked to name the site and both networks.
// Leaves the driver on the prompt.
async function openNetworkPrompt(env, key, from, to) {
const d = env.driver;
await d.switchToWindow(env.dappWindow);
await startRequest(d, key, "wallet_switchEthereumChain", [
{ chainId: to.chainId },
]);
const popup = await waitForApprovalWindow(d);
await d.switchToWindow(popup);
await d.waitVisible("#view-approve-network");
const screen = {
origin: await d.text("#approve-network-origin"),
current: await d.text("#approve-network-current"),
requested: await d.text("#approve-network-requested"),
};
assert(
isDeepStrictEqual(screen, {
origin: env.server.origin,
current: from.name,
requested: to.name,
}),
"the network switch prompt shows " + JSON.stringify(screen),
);
return popup;
}
// Only the user switches the network. A connected site's request opens a
// prompt, and until the user approves it the stored network does not move and
// no page is told it did (https://git.eeqj.de/sneak/AutistMask/issues/408).
// The wallet goes back to mainnet the same way at the end, for the transaction
// step after this one.
step(
"a site's network switch changes nothing until the user approves it",
async (env) => {
const d = env.driver;
const { mainnet, sepolia } = NETWORKS;
const before = await storedNetwork(env);
assert(
before.networkId === "mainnet",
"this step starts on mainnet, not on " + before.networkId,
);
const eventsBefore = (await chainChangedEvents(env)).length;
const rejected = await openNetworkPrompt(
env,
"switch-reject",
mainnet,
sepolia,
);
assert(
isDeepStrictEqual(await storedNetwork(env), before),
"the network moved while its prompt was still open",
);
// Nothing else closes this window, so a click that did not land
// leaves the request unanswered and the assertion below fails.
await d.switchToWindow(rejected);
await d.click("#btn-reject-network");
await d.switchToWindow(env.dappWindow);
await assertUserRejection(
d,
"switch-reject",
"the network switch rejection",
);
assert(
isDeepStrictEqual(await storedNetwork(env), before),
"a rejected network switch moved the network",
);
assert(
(await chainChangedEvents(env)).length === eventsBefore,
"a rejected network switch told the page the chain changed",
);
await openNetworkPrompt(env, "switch-approve", mainnet, sepolia);
await d.click("#btn-approve-network");
await d.switchToWindow(env.dappWindow);
let outcome = await settleRequest(d, "switch-approve");
assert(
outcome.settled === "resolved" && outcome.result === null,
"the approved network switch did not resolve: " +
JSON.stringify(outcome),
);
assert(
(await storedNetwork(env)).networkId === "sepolia",
"the approved network switch did not move the network",
);
const events = await chainChangedEvents(env, eventsBefore + 1);
assert(
events.length === eventsBefore + 1 &&
events[events.length - 1].data === sepolia.chainId,
"the page was not told of the approved switch: " +
JSON.stringify(events),
);
await openNetworkPrompt(env, "switch-restore", sepolia, mainnet);
await d.click("#btn-approve-network");
await d.switchToWindow(env.dappWindow);
outcome = await settleRequest(d, "switch-restore");
assert(
outcome.settled === "resolved",
"switching back to mainnet did not resolve: " +
JSON.stringify(outcome),
);
assert(
isDeepStrictEqual(await storedNetwork(env), before),
"switching back did not restore the mainnet network and endpoints",
);
await d.switchToWindow(env.dappWindow);
},
);
step( step(
"eth_sendTransaction shows the transaction and returns its hash", "eth_sendTransaction shows the transaction and returns its hash",
async (env) => { async (env) => {
+1 -26
View File
@@ -333,33 +333,8 @@ async function launch(routeOpts) {
const PASSWORD = "e2e-harness-password"; const PASSWORD = "e2e-harness-password";
// Waits until the page shows `selector`, not only until it lays out. Until the
// page's stylesheet has applied, the `hidden` class that showView() keeps on
// every view but the current one hides nothing and every view lays out, so a
// test could read a screen before the page's script had filled it
// (https://git.eeqj.de/sneak/AutistMask/issues/502). So the page must also
// have finished loading, which it does only after its stylesheet, and neither
// the element nor anything enclosing it may carry `hidden`. Polled on a timer:
// animation frames are not guaranteed to a window that is not in front.
async function visible(page, selector, timeout = 15000) { async function visible(page, selector, timeout = 15000) {
await page await page.waitForSelector(selector, { state: "visible", timeout });
.waitForFunction(
(sel) => {
const el = document.querySelector(sel);
if (document.readyState !== "complete" || !el) return false;
if (el.closest(".hidden")) return false;
if (getComputedStyle(el).visibility !== "visible") return false;
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;
},
selector,
{ polling: 50, timeout },
)
.catch((e) => {
throw new Error(
"the page did not show " + selector + ": " + e.message,
);
});
} }
// An empty WebAssembly module: magic number and version header, no // An empty WebAssembly module: magic number and version header, no
+13 -313
View File
@@ -113,18 +113,6 @@ test("the popup is drawn in the monospace font it declares (#418)", async (env)
); );
}); });
// The same stylesheet sets the size on body to 75%, which would draw the
// popup at 12px.
test("the popup's text is drawn at the 14px text-sm declares (#456)", async (env) => {
const size = await env.page.evaluate(
() => getComputedStyle(document.body).fontSize,
);
assert(
size === "14px",
"the popup's text is drawn at " + size + ", not at the 14px of text-sm",
);
});
// The empirical half of #182. The manifest change is only a claim about // The empirical half of #182. The manifest change is only a claim about
// what the CSP permits; this is the observation. Two things have to hold // what the CSP permits; this is the observation. Two things have to hold
// together, and the run covers both: the popup realm compiles WASM (here), // together, and the run covers both: the popup realm compiles WASM (here),
@@ -821,7 +809,7 @@ async function secretScreenState(page, view) {
return page.evaluate( return page.evaluate(
(v) => ({ (v) => ({
value: document.getElementById(v + "-value").textContent, value: document.getElementById(v + "-value").textContent,
error: document.getElementById(v + "-password-error").textContent, error: document.getElementById(v + "-flash").textContent,
html: document.getElementById("view-" + v).innerHTML, html: document.getElementById("view-" + v).innerHTML,
resultHidden: document resultHidden: document
.getElementById(v + "-result") .getElementById(v + "-result")
@@ -918,8 +906,7 @@ test("a wrong password reveals nothing (#161)", async (env) => {
await env.page.click("#btn-show-phrase-reveal"); await env.page.click("#btn-show-phrase-reveal");
await env.page.waitForFunction( await env.page.waitForFunction(
() => () =>
document.getElementById("show-phrase-password-error").textContent document.getElementById("show-phrase-flash").textContent.length > 0,
.length > 0,
null, null,
{ timeout: 60000 }, { timeout: 60000 },
); );
@@ -2006,14 +1993,13 @@ test("an over-long flash message keeps to one line (#252)", async (env) => {
// Every screen that asks for a password reserves room for one line of error. // Every screen that asks for a password reserves room for one line of error.
// The two on the dApp approval screens also have a border and padding, which // The two on the dApp approval screens also have a border and padding, which
// that reserved height has to cover too. The add wallet screen's line sits // that reserved height has to cover too.
// beside its button rather than above it, and has its own test below.
const PASSWORD_ERROR_CONTAINERS = [ const PASSWORD_ERROR_CONTAINERS = [
"approve-tx-error", "approve-tx-error",
"approve-sign-error", "approve-sign-error",
"export-privkey-password-error", "export-privkey-flash",
"show-phrase-password-error", "show-phrase-flash",
"delete-wallet-password-error", "delete-wallet-flash",
"confirm-tx-password-error", "confirm-tx-password-error",
]; ];
@@ -2078,107 +2064,6 @@ test("a password error moves nothing on any screen (#297)", async (env) => {
} }
}); });
// ------------------------------------------ add wallet Import button (#493)
// At 360x600 the add wallet screen's Import button already starts near the
// bottom of the popup, so its password error line sits beside the button
// rather than above it. Measures the button and the line empty and again
// filled with the longest error addWallet.js puts there. Runs in the page.
function measureImportButton() {
const button = document.getElementById("btn-add-wallet-confirm");
const line = document.getElementById("add-wallet-password-error");
const measure = () => {
const b = button.getBoundingClientRect();
const l = line.getBoundingClientRect();
return {
buttonTop: b.top + window.scrollY,
buttonBottom: b.bottom + window.scrollY,
lineTop: l.top + window.scrollY,
lineBottom: l.bottom + window.scrollY,
};
};
const empty = measure();
line.textContent = "Password must be at least 12 characters.";
line.style.visibility = "visible";
const filled = measure();
line.textContent = "";
line.style.visibility = "hidden";
return { empty, filled };
}
test("the add wallet password error leaves Import where it was (#493)", async (env) => {
const page = await openPopup(env.ctx, env.popupUrl);
try {
await page.setViewportSize(POPUP_VIEWPORT);
// Brought up by toggling classes, as in the test above. The note
// addWallet.js shows once a wallet exists is the only part of the
// screen that differs between the first wallet and a later one.
await page.evaluate(() => {
const screen = document.getElementById("view-add-wallet");
for (const view of document.querySelectorAll(".view")) {
view.classList.toggle("hidden", view !== screen);
}
});
for (const walletExists of [false, true]) {
await page.evaluate(
(shown) =>
document
.getElementById("add-wallet-separate-password-note")
.classList.toggle("hidden", !shown),
walletExists,
);
for (const tab of ["tab-mnemonic", "tab-privkey", "tab-xprv"]) {
await page.click("#" + tab);
const { empty, filled } =
await page.evaluate(measureImportButton);
const where =
"#" +
tab +
(walletExists
? " with a wallet already added"
: " for the first wallet");
// Printed pass or fail, as the dust threshold test does.
console.log(
"# add wallet Import top, " +
where +
": " +
empty.buttonTop +
"px",
);
for (const m of [empty, filled]) {
assert(
m.lineTop >= m.buttonTop &&
m.lineBottom <= m.buttonBottom,
"the error line on " +
where +
" does not fit beside Import, so it adds height: " +
JSON.stringify(m),
);
}
assert(
filled.buttonTop === empty.buttonTop,
"Import moved " +
(filled.buttonTop - empty.buttonTop) +
"px when the error appeared on " +
where,
);
if (!walletExists) {
assert(
empty.buttonTop < POPUP_VIEWPORT.height,
"Import starts at " +
empty.buttonTop +
"px on " +
where +
", below the fold",
);
}
}
}
} finally {
await page.close();
}
});
// --------------------------------------------- confirmation screen (#238) // --------------------------------------------- confirmation screen (#238)
// //
// The screen that decides what gets signed. The arithmetic underneath it // The screen that decides what gets signed. The arithmetic underneath it
@@ -3511,7 +3396,7 @@ async function closeApprovalPages(ctx) {
} }
// Click a button whose own handler closes the window it lives in — every // Click a button whose own handler closes the window it lives in — every
// Reject, Allow on the site prompt, and Switch on the network switch prompt. // Reject, and Allow on the site prompt.
// //
// page.click() dispatches the click and then waits for the renderer to // page.click() dispatches the click and then waits for the renderer to
// acknowledge it, and a page torn down by the handler never gets to. The // acknowledge it, and a page torn down by the handler never gets to. The
@@ -3526,13 +3411,12 @@ async function closeApprovalPages(ctx) {
// #btn-reject-sign, #btn-reject-tx — their disconnect leaves the approval // #btn-reject-sign, #btn-reject-tx — their disconnect leaves the approval
// pending, so a click that never landed leaves the dApp promise unsettled // pending, so a click that never landed leaves the dApp promise unsettled
// and the assertion after the call fails on its own. // and the assertion after the call fails on its own.
// #btn-approve, #btn-approve-network — only a decision resolves the promise, // #btn-approve — only a decision resolves the promise, and a swallowed click
// and a swallowed click cannot produce settled === "resolved". // cannot produce settled === "resolved".
// #btn-reject on the site prompt, #btn-reject-network — NOT self-proving. A // #btn-reject on the site prompt — NOT self-proving. A page that went away
// page that went away without the click landing disconnects the approval // without the click landing disconnects the approval port, the background
// port, the background settles that as 4001, and 4001 is exactly what // settles that as 4001, and 4001 is exactly what assertUserRejection
// assertUserRejection accepts. Every call site arms the click trace below // accepts. Both call sites arm the click trace below and assert it.
// and asserts it.
// //
// A button that is missing or unclickable raises a different error, which is // A button that is missing or unclickable raises a different error, which is
// rethrown. // rethrown.
@@ -4402,190 +4286,6 @@ test("a prompt raised while another approval window has focus opens its own (#29
await assertUserRejection(env.dapp, "focus-sign", "the sign prompt"); await assertUserRejection(env.dapp, "focus-sign", "the sign prompt");
}); });
// The network fields of the stored record.
async function storedNetwork(page) {
const s = await storedRecord(page);
return {
networkId: s.networkId,
rpcUrl: s.rpcUrl,
blockscoutUrl: s.blockscoutUrl,
};
}
// Every chainChanged event the test page has been sent, waiting up to
// `timeout` for there to be `count` of them: the background sends the event
// alongside its answer to the request, so it can arrive just after it.
async function chainChangedEvents(page, count = 0, timeout = 5000) {
const deadline = Date.now() + timeout;
for (;;) {
const events = (await dappMessages(page, "AUTISTMASK_EVENT")).filter(
(m) => m.eventName === "chainChanged",
);
if (events.length >= count || Date.now() > deadline) return events;
await sleep(50);
}
}
// Ask, from the test page, to switch from network `from` to network `to`, and
// return the prompt that opens, checked to name the site and both networks.
async function openNetworkPrompt(env, key, from, to) {
await startRequest(env.dapp, key, "wallet_switchEthereumChain", [
{ chainId: to.chainId },
]);
const popup = await waitForApprovalWindow(env.ctx);
await visible(popup, "#view-approve-network");
const screen = await popup.evaluate(() => ({
origin: document.getElementById("approve-network-origin").textContent,
current: document.getElementById("approve-network-current").textContent,
requested: document.getElementById("approve-network-requested")
.textContent,
}));
assert(
isDeepStrictEqual(screen, {
origin: DAPP_ORIGIN,
current: from.name,
requested: to.name,
}),
"the network switch prompt shows " + JSON.stringify(screen),
);
return popup;
}
// Only the user switches the network. A connected site's request opens a
// prompt, and until the user approves it the stored network does not move and
// no page is told it did (https://git.eeqj.de/sneak/AutistMask/issues/408).
// The wallet goes back to mainnet the same way at the end, for the tests after
// this one.
test("a site's network switch changes nothing until the user approves it (#408)", async (env) => {
const { mainnet, sepolia } = NETWORKS;
const before = await storedNetwork(env.page);
assert(
before.networkId === "mainnet",
"this test starts on mainnet, not on " + before.networkId,
);
const eventsBefore = (await chainChangedEvents(env.dapp)).length;
const rejected = await openNetworkPrompt(
env,
"switch-reject",
mainnet,
sepolia,
);
try {
assert(
isDeepStrictEqual(await storedNetwork(env.page), before),
"the network moved while its prompt was still open",
);
// Closing the prompt unanswered is also a rejection, so the click
// itself is witnessed.
await armClickTrace(env, rejected, "#btn-reject-network");
await clickAndClose(rejected, "#btn-reject-network");
await assertClickLanded(env, "#btn-reject-network");
await assertUserRejection(
env.dapp,
"switch-reject",
"the network switch rejection",
);
} finally {
await closeApprovalPages(env.ctx);
}
assert(
isDeepStrictEqual(await storedNetwork(env.page), before),
"a rejected network switch moved the network",
);
assert(
(await chainChangedEvents(env.dapp)).length === eventsBefore,
"a rejected network switch told the page the chain changed",
);
const approved = await openNetworkPrompt(
env,
"switch-approve",
mainnet,
sepolia,
);
let outcome;
try {
await clickAndClose(approved, "#btn-approve-network");
outcome = await settleRequest(env.dapp, "switch-approve");
} finally {
await closeApprovalPages(env.ctx);
}
assert(
outcome.settled === "resolved" && outcome.result === null,
"the approved network switch did not resolve: " +
JSON.stringify(outcome),
);
assert(
(await storedNetwork(env.page)).networkId === "sepolia",
"the approved network switch did not move the network",
);
const events = await chainChangedEvents(env.dapp, eventsBefore + 1);
assert(
events.length === eventsBefore + 1 &&
events[events.length - 1].data === sepolia.chainId,
"the page was not told of the approved switch: " +
JSON.stringify(events),
);
const restored = await openNetworkPrompt(
env,
"switch-restore",
sepolia,
mainnet,
);
try {
await clickAndClose(restored, "#btn-approve-network");
outcome = await settleRequest(env.dapp, "switch-restore");
} finally {
await closeApprovalPages(env.ctx);
}
assert(
outcome.settled === "resolved",
"switching back to mainnet did not resolve: " + JSON.stringify(outcome),
);
assert(
isDeepStrictEqual(await storedNetwork(env.page), before),
"switching back did not restore the mainnet network and endpoints",
);
});
// Switching the network in Settings tells the page too, as an approved site
// request does (https://git.eeqj.de/sneak/AutistMask/issues/500). The wallet
// goes back to mainnet the same way at the end.
test("a network switch in Settings tells the page (#500)", async (env) => {
const { mainnet, sepolia } = NETWORKS;
const before = await storedNetwork(env.page);
assert(
before.networkId === "mainnet",
"this test starts on mainnet, not on " + before.networkId,
);
const eventsBefore = (await chainChangedEvents(env.dapp)).length;
await openSettings(env.page);
await env.page.selectOption("#settings-network", "sepolia");
let events = await chainChangedEvents(env.dapp, eventsBefore + 1);
assert(
events.length === eventsBefore + 1 &&
events[events.length - 1].data === sepolia.chainId,
"the page was not told of the switch to Sepolia: " +
JSON.stringify(events),
);
await env.page.selectOption("#settings-network", "mainnet");
events = await chainChangedEvents(env.dapp, eventsBefore + 2);
assert(
events.length === eventsBefore + 2 &&
events[events.length - 1].data === mainnet.chainId,
"the page was not told of the switch back to mainnet: " +
JSON.stringify(events),
);
assert(
isDeepStrictEqual(await storedNetwork(env.page), before),
"switching back did not restore the mainnet network and endpoints",
);
});
// The closing pass over both boundaries at once. Every message the section // The closing pass over both boundaries at once. Every message the section
// put on either channel is re-read here and required to be free of the // put on either channel is re-read here and required to be free of the
// password — and required to be there at all, method by method, so the // password — and required to be there at all, method by method, so the
+4 -6
View File
@@ -207,7 +207,7 @@ describe("a decrypt still running when the screen is left", () => {
}); });
// Same hole on the failure path: a wrong-password error written after // Same hole on the failure path: a wrong-password error written after
// the wipe would restore the error line on a screen the user has left. // the wipe would restore the flash line on a screen the user has left.
test("never writes the failure message either", async () => { test("never writes the failure message either", async () => {
const { helpers, vault, exportPrivkey } = load(); const { helpers, vault, exportPrivkey } = load();
exportPrivkey.show(0, 0); exportPrivkey.show(0, 0);
@@ -217,10 +217,8 @@ describe("a decrypt still running when the screen is left", () => {
reveal.reject(new Error("decryption failed")); reveal.reject(new Error("decryption failed"));
await reveal.pending; await reveal.pending;
expect(node("export-privkey-password-error").textContent).toBe(""); expect(node("export-privkey-flash").textContent).toBe("");
expect(node("export-privkey-password-error").style.visibility).toBe( expect(node("export-privkey-flash").style.visibility).toBe("hidden");
"hidden",
);
}); });
}); });
@@ -262,7 +260,7 @@ describe("a reveal that is not interrupted", () => {
await reveal.pending; await reveal.pending;
expect(node("export-privkey-value").textContent).toBe(""); expect(node("export-privkey-value").textContent).toBe("");
expect(node("export-privkey-password-error").textContent).toBe( expect(node("export-privkey-flash").textContent).toBe(
"That password is incorrect. Please try again.", "That password is incorrect. Please try again.",
); );
}); });
-33
View File
@@ -1,33 +0,0 @@
// The toolbar icons in icons/ are drawn by script/lib/icons.js (`make icons`).
// Each committed file must hold exactly the image it draws, the same IHDR and
// every pixel, so the drawing and the files cannot drift apart. The compressed
// bytes are not compared: the committed files were compressed by stock zlib,
// and node's bundled zlib compresses the same pixels differently.
const fs = require("fs");
const path = require("path");
const { icons } = require("../manifest/chrome.json");
const { drawIcon, decodePng } = require("../script/lib/icons");
describe("toolbar icons", () => {
test.each(Object.entries(icons))(
"the %spx icon %s holds the image script/lib/icons.js draws",
(size, file) => {
const side = Number(size);
const committed = decodePng(
fs.readFileSync(path.join(__dirname, "..", file)),
);
const drawn = decodePng(drawIcon(side));
expect(committed.header).toEqual(drawn.header);
// Each row is its filter type byte, then 4 bytes per pixel.
expect(drawn.rows.length).toBe(side * (side * 4 + 1));
expect(committed.rows.length).toBe(drawn.rows.length);
// The offset of the first byte that differs, not a diff of all.
expect(
committed.rows.findIndex((byte, i) => byte !== drawn.rows[i]),
).toBe(-1);
},
);
});
+4 -4
View File
@@ -345,10 +345,10 @@ describe.each([
}); });
// A transaction's value and fee are in the native currency of the network the // A transaction's value and fee are in the native currency of the network the
// transaction is on, which need not be the active one. The active network can // transaction is on, which need not be the active one. A site can switch the
// change, in Settings or when the user approves a site's request, after a // active network after its transaction is prepared and back before it is
// transaction is prepared and change back before it is signed, and a popup // signed, and a popup opened after a switch shows a sent or listed transaction
// opened after a switch shows a sent or listed transaction again. The wallet's balances follow the active network; these do not. // again. The wallet's balances follow the active network; these do not.
describe.each([ describe.each([
["mainnet", "sepolia", "ETH"], ["mainnet", "sepolia", "ETH"],
["sepolia", "mainnet", "SepoliaETH"], ["sepolia", "mainnet", "SepoliaETH"],
-159
View File
@@ -1,159 +0,0 @@
// Every screen that asks for a password shows a password error the same way:
// through showError() and hideError() in src/popup/views/helpers.js, in a
// fixed-height error line below the password field, and never in the flash
// line at the top of the popup
// (https://git.eeqj.de/sneak/AutistMask/issues/493). These boot the real popup
// over src/popup/index.html, so each error line has to exist in the markup,
// and check that the error appears in it and clears again.
jest.mock("../src/shared/vault", () => ({
decryptWithPassword: jest.fn(),
encryptWithPassword: jest.fn(),
}));
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
} = require("./support/popupBoot");
const PASSWORD = "correct horse battery staple";
const WRONG_PASSWORD = "That password is incorrect. Please try again.";
afterEach(() => {
cleanupPopup();
});
// The error line as the user sees it.
function errorLine(page, id) {
return {
inMarkup: page.document.authoredIds.has(id),
text: page.text(id),
visibility: page.node(id).style.visibility,
};
}
function shown(text) {
return { inMarkup: true, text, visibility: "visible" };
}
const cleared = { inMarkup: true, text: "", visibility: "hidden" };
describe("the add wallet screen", () => {
const ERROR = "add-wallet-password-error";
// First run: Welcome, "Add wallet", then the die for a valid phrase.
async function openAddWallet() {
const page = await bootPopup(undefined);
await page.click("btn-welcome-add");
await page.click("btn-generate-phrase");
return page;
}
function setPasswords(page, password, confirm) {
page.node("add-wallet-password").value = password;
page.node("add-wallet-password-confirm").value = confirm;
}
test("shows a password problem below the password fields, and clears it on the next press", async () => {
const page = await openAddWallet();
setPasswords(page, "short", "short");
await page.click("btn-add-wallet-confirm");
expect(errorLine(page, ERROR)).toEqual(
shown("Password must be at least 12 characters."),
);
expect(page.text("flash-msg")).toBe("");
// The password is fixed and the phrase emptied: the password error
// goes, and the phrase problem is still reported in the flash line.
setPasswords(page, PASSWORD, PASSWORD);
page.node("wallet-mnemonic").value = "";
await page.click("btn-add-wallet-confirm");
expect(errorLine(page, ERROR)).toEqual(cleared);
expect(page.text("flash-msg")).toBe(
"Enter a recovery phrase, or press the die.",
);
// Leaving clears the flash line and stops its timer, which would
// otherwise fire after this page is gone.
await page.click("btn-add-wallet-back");
});
test("clears the error when the screen is shown again", async () => {
const page = await openAddWallet();
setPasswords(page, PASSWORD, PASSWORD + " typo");
await page.click("btn-add-wallet-confirm");
expect(errorLine(page, ERROR)).toEqual(
shown("Passwords do not match."),
);
await page.click("btn-add-wallet-back");
await page.click("btn-welcome-add");
expect(errorLine(page, ERROR)).toEqual(cleared);
});
});
describe.each([
{
screen: "the private key export screen",
open: () => require("../src/popup/views/exportPrivkey").show(0, 0),
field: "export-privkey-password",
button: "btn-export-privkey-confirm",
error: "export-privkey-password-error",
},
{
screen: "the recovery phrase screen",
open: () => require("../src/popup/views/showPhrase").show(0),
field: "show-phrase-password",
button: "btn-show-phrase-reveal",
error: "show-phrase-password-error",
},
{
screen: "the delete wallet screen",
open: () => require("../src/popup/views/deleteWallet").show(0),
field: "delete-wallet-password",
button: "btn-delete-wallet-confirm",
error: "delete-wallet-password-error",
},
])("$screen", ({ open, field, button, error }) => {
// Opens the screen and enters a password the vault rejects.
async function failedAttempt() {
const page = await bootPopup(unversionedValidProfile());
open();
const { decryptWithPassword } = require("../src/shared/vault");
decryptWithPassword.mockRejectedValue(new Error("wrong password"));
page.node(field).value = "not the password";
await page.click(button);
return { page, decryptWithPassword };
}
test("shows a wrong password below the password field", async () => {
const { page } = await failedAttempt();
expect(errorLine(page, error)).toEqual(shown(WRONG_PASSWORD));
});
test("clears the error while the next password is checked", async () => {
const { page, decryptWithPassword } = await failedAttempt();
let rejectDecrypt;
decryptWithPassword.mockReturnValue(
new Promise((resolve, reject) => {
rejectDecrypt = reject;
}),
);
page.node(field).value = "another guess";
const pressed = page.click(button);
await page.settle();
expect(errorLine(page, error)).toEqual(cleared);
rejectDecrypt(new Error("wrong password"));
await pressed;
expect(errorLine(page, error)).toEqual(shown(WRONG_PASSWORD));
});
test("clears the error when the screen is shown again", async () => {
const { page } = await failedAttempt();
open();
expect(errorLine(page, error)).toEqual(cleared);
});
});
+48 -144
View File
@@ -49,37 +49,22 @@
// every path a stored record takes, and the difference is the whole of what // every path a stored record takes, and the difference is the whole of what
// this file does not cover: // this file does not cover:
// //
// - Only the values in the table, in the SLOT arrangement below. On the // - Only the values in the table, in the SLOT arrangement below: four value
// restore path the twelve fields the router does not read are corrupted // combinations per view, not the product of twelve fields. A dereference
// together, every field on the same slot, so a view gets four value // reached only under a pairing no slot produces is not driven at all.
// combinations of them, not their product. A branch entered only when one // - Only what a stored record reaches by ITSELF. A view only forward
// of them is truthy and another falsy is reached only where the falsy // navigation opens, and anything behind a click, is not driven.
// slot happens to pair a field that cannot be falsy with one that is. // - Nothing about the paths a HEALTHY profile takes, which is most of the
// Each field the router reads is corrupted alone, over an otherwise // popup. This file is a floor under one defect class, not a proof about
// well-formed record. // the renderers.
// - Only what a stored record reaches by ITSELF, as the boot renders it. A
// view only forward navigation opens, anything behind a click, and
// anything behind a timer (bootPopup() records every interval, and this
// file never runs one) is not driven.
// - Of the paths a HEALTHY profile takes, only its boot onto each
// restorable view ("the base profile the sweep corrupts" below). The rest
// of the popup is not covered: this file is a floor under one defect
// class, not a proof about the renderers.
// //
// Within that boundary it is unconditional: if a boot that corrupts a field // Within that boundary it is unconditional: if one of these boots leaves the
// leaves the popup unhealthy, this file goes red — including when it takes // popup unhealthy or off the view it stored, this file goes red — including
// two corrupted fields at once, because the verdict is the combined boot // when it takes two corrupted fields at once, because the verdict is the
// itself and the per-field re-boot below can only decorate the message. That // combined boot itself and the per-field re-boot below can only decorate the
// last part is the one thing an earlier version got wrong: it asserted on the // message. That last part is the one thing an earlier version got wrong: it
// per-field list, so an observed dead popup that no single field reproduced // asserted on the per-field list, so an observed dead popup that no single
// was reported green. // field reproduced was reported green.
//
// Where the popup lands is held for some of those boots and not others. The
// combined boot must land on the view it stored, and each `hostileRestore`
// value must land on its view, or fall back to Home, as its entry declares.
// The boots in "a hostile routing value restoring onto" are held to health
// alone, because a value in a field the router reads legitimately changes
// which view renders; so are the boots onto Home, which store no view.
// //
// Booting every field separately at every value would be several hundred boots // Booting every field separately at every value would be several hundred boots
// and most of the suite's budget; this is forty-four. Widening it further is // and most of the suite's budget; this is forty-four. Widening it further is
@@ -143,11 +128,7 @@ const sweptValues = (row) => [...row.hostile, ...(row.falsy || [])];
// list short and pointed. `floorOnly` is extra values checked against the // list short and pointed. `floorOnly` is extra values checked against the
// floor alone, which is pure and free. `hostileRestore` is extra values driven // floor alone, which is pure and free. `hostileRestore` is extra values driven
// through the restore path only, for a value that means nothing until a // through the restore path only, for a value that means nothing until a
// particular branch's gate has let it past. Each of its entries names the // particular branch's gate has let it past.
// `views` it is driven onto and declares whether the boot lands on them
// (`restored: true`) or falls back to Home (`restored: false`), so a value
// written for one renderer cannot stop reaching it unnoticed. A value driven
// onto every restorable view is written with everyRestorableView() below.
// //
// `falsy` is the other POLARITY of a swept field, driven for the same reason. // `falsy` is the other POLARITY of a swept field, driven for the same reason.
// It is not a value src/ never writes — for three of these fields it is the // It is not a value src/ never writes — for three of these fields it is the
@@ -158,23 +139,6 @@ const sweptValues = (row) => [...row.hostile, ...(row.falsy || [])];
// falsy value stored under that field comes back TRUTHY from the floor, so no // falsy value stored under that field comes back TRUTHY from the floor, so no
// `!state.x` branch is reachable from a stored record at all. // `!state.x` branch is reachable from a stored record at all.
// The `hostileRestore` entries for a value driven onto every restorable view:
// it falls back to Home on the views listed in `fallsBackOn` and lands on every
// other one, so a view added to RESTORABLE_VIEWS is driven, and expected to
// land, without editing the row.
function everyRestorableView(value, fallsBackOn) {
return [
{ value, views: fallsBackOn, restored: false },
{
value,
views: [...RESTORABLE_VIEWS].filter(
(view) => !fallsBackOn.includes(view),
),
restored: true,
},
];
}
const CONTRACT = [ const CONTRACT = [
{ {
field: "wallets", field: "wallets",
@@ -316,38 +280,28 @@ const CONTRACT = [
kind: KIND.SCALAR, kind: KIND.SCALAR,
// The prototype members are the whole point: `wallets["map"]` is // The prototype members are the whole point: `wallets["map"]` is
// TRUTHY, so hasValidAddress()'s `&&` does not short-circuit and // TRUTHY, so hasValidAddress()'s `&&` does not short-circuit and
// `.addresses[…]` throws. A stale INTEGER is the safe case and has to // `.addresses[…]` throws. A stale INTEGER is the safe case.
// stay so. 5 is one, out of range for the one wallet in the profile, hostile: ["map", "__proto__", { a: 1 }],
// and it is also this field's truthy polarity: every other value here
// comes back from the floor as null.
hostile: ["map", "__proto__", { a: 1 }, 5],
floorOnly: ["length", "constructor", "toString", "0", -1, 1.5, true], floorOnly: ["length", "constructor", "toString", "0", -1, 1.5, true],
holds: isIndexOrNull, holds: isIndexOrNull,
// SCALAR, and swept anyway: the restore path is precisely why this // SCALAR, and swept anyway: the restore path is precisely why this
// field gained a floor, so the sweep is the regression guard on it. // field gained a floor, so the sweep is the regression guard on it.
alsoSweep: true, alsoSweep: true,
routes: true, routes: true,
// Comes back from the floor as null, which hasValidAddress() reads as // A stale INTEGER index, which reaches the restore path by a different
// nothing selected: the popup falls back to Home on the five views // route from the prototype members above — falsy or out of range
// that need an address, and lands on every other one. // rather than truthy — and has to keep being the safe case.
hostileRestore: everyRestorableView("length", [ hostileRestore: [{ value: "length" }, { value: 5 }],
"address",
"address-token",
"receive",
"transaction",
"confirm-tx",
]),
}, },
{ {
field: "selectedAddress", field: "selectedAddress",
kind: KIND.SCALAR, kind: KIND.SCALAR,
// 5 for the same reason as in selectedWallet: a stale index, and the hostile: ["map", "__proto__", { a: 1 }],
// one value here still truthy after the floor.
hostile: ["map", "__proto__", { a: 1 }, 5],
floorOnly: ["length", "constructor", "toString", "0", -1, 1.5, true], floorOnly: ["length", "constructor", "toString", "0", -1, 1.5, true],
holds: isIndexOrNull, holds: isIndexOrNull,
alsoSweep: true, alsoSweep: true,
routes: true, routes: true,
hostileRestore: [{ value: 5 }],
}, },
{ {
field: "currentView", field: "currentView",
@@ -371,9 +325,7 @@ const CONTRACT = [
// container shapes below onto every restorable view; hostileRestore // container shapes below onto every restorable view; hostileRestore
// adds the records that PASS a branch's gate and then hand its // adds the records that PASS a branch's gate and then hand its
// renderer something it dereferences, which is where the entries are // renderer something it dereferences, which is where the entries are
// actually decided. The guard refuses each single-view record below, // actually decided.
// so each is declared to fall back to Home: one that started landing
// would be reaching the renderer it was written against.
hostile: [42, "notarecord", { a: 1 }, [1, 2]], hostile: [42, "notarecord", { a: 1 }, [1, 2]],
// `structuredClone(saved.viewData || {})`: the container is never falsy // `structuredClone(saved.viewData || {})`: the container is never falsy
// in state whatever was stored, so no `!state.viewData` branch exists to // in state whatever was stored, so no `!state.viewData` branch exists to
@@ -382,20 +334,11 @@ const CONTRACT = [
hostileRestore: [ hostileRestore: [
// success-tx passes on `data.hash`, and renderSuccess() then calls // success-tx passes on `data.hash`, and renderSuccess() then calls
// toAddressHtml(d.to) -> addressTitle() -> address.toLowerCase(). // toAddressHtml(d.to) -> addressTitle() -> address.toLowerCase().
{ { value: { hash: "0x1" }, views: ["success-tx"] },
value: { hash: "0x1" }, { value: { hash: "0x1", to: 42 }, views: ["success-tx"] },
views: ["success-tx"],
restored: false,
},
{
value: { hash: "0x1", to: 42 },
views: ["success-tx"],
restored: false,
},
{ {
value: { hash: "0x1", to: ADDRESS, decoded: { details: 7 } }, value: { hash: "0x1", to: ADDRESS, decoded: { details: 7 } },
views: ["success-tx"], views: ["success-tx"],
restored: false,
}, },
{ {
value: { value: {
@@ -404,25 +347,12 @@ const CONTRACT = [
decoded: { details: [{ address: 42 }] }, decoded: { details: [{ address: 42 }] },
}, },
views: ["success-tx"], views: ["success-tx"],
restored: false,
}, },
// error-tx passes on `data.message`, same dereference. // error-tx passes on `data.message`, same dereference.
{ { value: { message: "boom" }, views: ["error-tx"] },
value: { message: "boom" }, { value: { message: "boom", to: 42 }, views: ["error-tx"] },
views: ["error-tx"],
restored: false,
},
{
value: { message: "boom", to: 42 },
views: ["error-tx"],
restored: false,
},
// transaction passes on `data.tx`. // transaction passes on `data.tx`.
{ { value: { tx: { hash: "0x1" } }, views: ["transaction"] },
value: { tx: { hash: "0x1" } },
views: ["transaction"],
restored: false,
},
{ {
value: { value: {
tx: { tx: {
@@ -433,14 +363,9 @@ const CONTRACT = [
}, },
}, },
views: ["transaction"], views: ["transaction"],
restored: false,
}, },
// confirm-tx passes on `data.pendingTx`. // confirm-tx passes on `data.pendingTx`.
{ { value: { pendingTx: { amount: "1" } }, views: ["confirm-tx"] },
value: { pendingTx: { amount: "1" } },
views: ["confirm-tx"],
restored: false,
},
{ {
value: { value: {
pendingTx: { pendingTx: {
@@ -451,7 +376,6 @@ const CONTRACT = [
}, },
}, },
views: ["confirm-tx"], views: ["confirm-tx"],
restored: false,
}, },
// wait-tx passes on `pendingWait.hash`; restoreWait() has checked // wait-tx passes on `pendingWait.hash`; restoreWait() has checked
// the fields below it since it was written, and this is the // the fields below it since it was written, and this is the
@@ -464,29 +388,20 @@ const CONTRACT = [
}, },
}, },
views: ["wait-tx"], views: ["wait-tx"],
restored: false,
}, },
// A record that passes EVERY branch's gate at once, driven onto // A record that passes EVERY branch's gate at once, driven onto
// every restorable view: a branch a view does not read must stay // every restorable view: a branch a view does not read must stay
// one it does not read. The five views with a viewData branch // one it does not read, and each renderer must survive the fields
// refuse it; every other one renders it, and must survive the // another branch left behind.
// fields every branch left behind. {
...everyRestorableView( value: {
{
hash: "0x1", hash: "0x1",
message: "boom", message: "boom",
tx: { hash: "0x1" }, tx: { hash: "0x1" },
pendingTx: { amount: "1" }, pendingTx: { amount: "1" },
pendingWait: { hash: "0x1" }, pendingWait: { hash: "0x1" },
}, },
[ },
"confirm-tx",
"transaction",
"wait-tx",
"success-tx",
"error-tx",
],
),
], ],
}, },
{ {
@@ -668,11 +583,6 @@ const HEALTHY = { errors: [], blank: false };
// set is all-truthy by construction, so without a falsy slot a dereference // set is all-truthy by construction, so without a falsy slot a dereference
// behind `if (!state.x)` is never reached on the boot that corrupts x — the // behind `if (!state.x)` is never reached on the boot that corrupts x — the
// same falsy-collapse blind spot the fields below were floored for. // same falsy-collapse blind spot the fields below were floored for.
//
// Only `hostile` and `falsy` values count. Those go through the sweep below,
// which covers every restorable view; a `hostileRestore` entry is driven onto
// only the views it names, so a polarity it alone supplied might reach a single
// renderer.
describe("both polarities of every swept field are driven", () => { describe("both polarities of every swept field are driven", () => {
const FALSY_STORED = [0, "", false, null]; const FALSY_STORED = [0, "", false, null];
const floored = (field, value) => const floored = (field, value) =>
@@ -696,9 +606,10 @@ describe("both polarities of every swept field are driven", () => {
test(`${row.field}: truthy and falsy`, () => { test(`${row.field}: truthy and falsy`, () => {
// What the boots below actually drive, floored the way a renderer // What the boots below actually drive, floored the way a renderer
// sees it — not what the row says it drives. // sees it — not what the row says it drives.
const driven = sweptValues(row).map((value) => const driven = [
floored(row.field, value), ...sweptValues(row),
); ...(row.hostileRestore || []).map((entry) => entry.value),
].map((value) => floored(row.field, value));
expect({ expect({
truthy: driven.some((value) => Boolean(value)), truthy: driven.some((value) => Boolean(value)),
@@ -954,27 +865,20 @@ describe("every field the router does not read, corrupted at once, onto", () =>
// The values that only mean something on the restore path: a viewData that // The values that only mean something on the restore path: a viewData that
// PASSES a branch's gate and then hands its renderer something dereferenced, // PASSES a branch's gate and then hands its renderer something dereferenced,
// and a selectedWallet the floor turns into nothing selected. Each boot must // and the index values whose route through hasValidAddress() differs from the
// throw nothing and show exactly the view its entry says it lands on: its own, // row's own hostile set.
// or Home, so a value written for one renderer cannot stop reaching it and
// still pass.
describe("a restore-only hostile value onto", () => { describe("a restore-only hostile value onto", () => {
for (const row of CONTRACT) { for (const row of CONTRACT) {
for (const entry of row.hostileRestore || []) { for (const entry of row.hostileRestore || []) {
for (const view of entry.views) { for (const view of entry.views || RESTORABLE_VIEWS) {
test(`${view}: ${row.field} = ${JSON.stringify( test(`${view}: ${row.field} = ${JSON.stringify(
entry.value, entry.value,
)}`, async () => { )}`, async () => {
const env = await bootPopup( await expect(
restoringOnto(view, { [row.field]: entry.value }), bootHealth(
); restoringOnto(view, { [row.field]: entry.value }),
expect({ ),
errors: env.pageErrors, ).resolves.toEqual(HEALTHY);
visible: env.visibleViews(),
}).toEqual({
errors: [],
visible: [entry.restored ? view : "main"],
});
}); });
} }
} }
-13
View File
@@ -89,12 +89,6 @@ describe("vendored blocklist", () => {
expect(isPhishingDomain(LISTED[0].toUpperCase())).toBe(true); expect(isPhishingDomain(LISTED[0].toUpperCase())).toBe(true);
}); });
test("a hostname ending in a dot is the same host", () => {
expect(isPhishingDomain(LISTED[1] + ".")).toBe(true);
expect(isPhishingDomain("wallet." + LISTED[1] + ".")).toBe(true);
expect(isPhishingDomain(CLEAN[0] + ".")).toBe(false);
});
test("returns false for an empty or missing hostname", () => { test("returns false for an empty or missing hostname", () => {
expect(isPhishingDomain("")).toBe(false); expect(isPhishingDomain("")).toBe(false);
expect(isPhishingDomain(null)).toBe(false); expect(isPhishingDomain(null)).toBe(false);
@@ -139,13 +133,6 @@ describe("hostnameVariants", () => {
test("lowercases hostnames", () => { test("lowercases hostnames", () => {
expect(hostnameVariants("Evil.COM")).toEqual(["evil.com"]); expect(hostnameVariants("Evil.COM")).toEqual(["evil.com"]);
}); });
test("drops the trailing dot of a fully qualified hostname", () => {
expect(hostnameVariants("sub.evil.com.")).toEqual([
"sub.evil.com",
"evil.com",
]);
});
}); });
describe("domain hashing", () => { describe("domain hashing", () => {
+1 -6
View File
@@ -238,10 +238,6 @@ async function settle() {
* @param {object} [options] * @param {object} [options]
* @param {object} [options.storage] a storage stub from makeStorageStub(), for * @param {object} [options.storage] a storage stub from makeStorageStub(), for
* a test that needs to make writes fail or to watch the round trips. * a test that needs to make writes fail or to watch the round trips.
* @param {string} [options.search] the page URL's query string, such as
* "?approval=" and an id for the popup opened as an approval window.
* @param {object} [options.runtime] members of chrome.runtime that replace the
* stub's own, for a test that has to answer the background's messages.
* @returns {Promise<object>} handles onto the booted page. * @returns {Promise<object>} handles onto the booted page.
*/ */
async function bootPopup(stored, options) { async function bootPopup(stored, options) {
@@ -285,13 +281,12 @@ async function bootPopup(stored, options) {
sendMessage: jest.fn(async () => ({})), sendMessage: jest.fn(async () => ({})),
getURL: (p) => "chrome-extension://autistmask/" + p, getURL: (p) => "chrome-extension://autistmask/" + p,
onMessage: { addListener: () => {} }, onMessage: { addListener: () => {} },
...(options && options.runtime),
}, },
}; };
globalThis.document = document; globalThis.document = document;
globalThis.window = { globalThis.window = {
location: { location: {
search: (options && options.search) || "", search: "",
href: "chrome-extension://autistmask/src/popup/index.html", href: "chrome-extension://autistmask/src/popup/index.html",
reload: () => reloads.push(Date.now()), reload: () => reloads.push(Date.now()),
}, },
-64
View File
@@ -240,7 +240,6 @@ function request(data) {
beforeEach(() => { beforeEach(() => {
state.trackedTokens = []; state.trackedTokens = [];
state.wallets = []; state.wallets = [];
state.networkId = "mainnet";
}); });
describe("a token permission is warned about, naming spender and amount", () => { describe("a token permission is warned about, naming spender and amount", () => {
@@ -533,66 +532,3 @@ describe("the sign screen refuses a mismatched primary type", () => {
expect(node("btn-approve-sign").disabled).toBe(true); expect(node("btn-approve-sign").disabled).toBe(true);
}); });
}); });
// EIP-712: a wallet refuses typed data whose domain names a chain other than
// the active one, because the signature is valid on the chain it names
// (https://git.eeqj.de/sneak/AutistMask/issues/519). The wallet is on Sepolia
// and the site asks for a mainnet USDC Permit.
describe("the sign screen refuses typed data for another chain", () => {
const REFUSAL =
"This typed data is for chain id 1, but the active network is Sepolia Testnet (chain id 11155111), so it cannot be signed.";
function withChainId(chainId) {
return { ...PERMIT, domain: { ...PERMIT.domain, chainId } };
}
beforeEach(() => {
decryptWithPassword.mockClear();
state.networkId = "sepolia";
});
test("another chain shows the error, with Sign disabled", async () => {
await openSignScreen(PERMIT);
expect(node("approve-sign-error").textContent).toBe(REFUSAL);
expect(node("btn-approve-sign").disabled).toBe(true);
});
test("Sign clicked anyway decrypts and signs nothing", async () => {
const sent = await openSignScreen(PERMIT);
node("approve-sign-password").value = "any password";
await click("btn-approve-sign");
expect(decryptWithPassword).not.toHaveBeenCalled();
expect(sent.map((msg) => msg.type)).not.toContain(
"AUTISTMASK_SIGN_RESPONSE",
);
expect(node("approve-sign-error").textContent).toBe(REFUSAL);
});
// ethers signs 1, "1" and "0x1" as the same chain id.
test("the chain id is refused in each form ethers signs", () => {
for (const chainId of [1, "1", "0x1"]) {
expect(typedDataRefusal(request(withChainId(chainId)))).toBe(
REFUSAL,
);
}
});
test("a chain id that is not a number is refused", () => {
expect(typedDataRefusal(request(withChainId("mainnet")))).toBe(
"This typed data cannot be read, so it cannot be signed.",
);
});
test("the active chain leaves Sign enabled", async () => {
await openSignScreen(withChainId("0xaa36a7"));
expect(node("approve-sign-error").textContent).toBe("");
expect(node("btn-approve-sign").disabled).toBe(false);
});
test("no chain id leaves Sign enabled", async () => {
await openSignScreen(withChainId(undefined));
expect(node("approve-sign-error").textContent).toBe("");
expect(node("btn-approve-sign").disabled).toBe(false);
});
});
-12
View File
@@ -153,18 +153,6 @@ describe("the explanatory copy", () => {
expect(all).not.toMatch(/\blost\b|\bstolen\b|\bgone\b/i); expect(all).not.toMatch(/\blost\b|\bstolen\b|\bgone\b/i);
}); });
// The wallet holds the imported key but will not export it, so the copy
// must name every key the user may hold that reaches the addresses
// outside it.
test("it names the keys that reach the addresses outside this wallet", () => {
const body = defect.paragraphs.join(" ");
expect(body).toMatch(/private key export are disabled/);
expect(body).toMatch(
/with the extended private key you imported, if you kept a copy of it, or with the master extended private key or recovery phrase it came from, in software that repeats the same non-standard derivation/,
);
});
test("it says the wallet is not deleted and what the user can do", () => { test("it says the wallet is not deleted and what the user can do", () => {
const body = defect.paragraphs.join(" "); const body = defect.paragraphs.join(" ");