Author SHA1 Message Date
sneak 2a61ac5b03 fix: each browser-suite run builds and removes an image of its own (closes #547)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
script/test-e2e and script/test-e2e-firefox tagged their image with one
fixed name. When two clones ran a suite at once, the second build moved
that tag, the image store deleted the first run's now-untagged image, and
that suite exited 125 without running a test. Each run now tags its image
with the suite's name and twelve random hex digits and runs it by that
tag. The cleanup that runs on exit, Ctrl-C, kill and a closed terminal
removes the run's container if docker's client left it running, then the
image. The image-ID file that guarded against a moving tag is gone.
README.md's End-to-End Tests section says which endings clean up.

Model: opus-5-5
2026-10-14 18:14:57 +00:00
clawbot f4551dcb67 chore: bring the token-list enrichment tooling into script/lib (closes #495)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The two node programs and the data file from f7a2437 regenerate the name and
url of each token in src/shared/tokenList.js. They are now CommonJS under
script/lib/ and read ethereum-lists at a pinned commit. The apply step also
rewrites an entry that already has a name and url, so it runs on the current
list, and stops on an address that matches no list entry. Exported as
applyEnrichment, it is run by tests/applyEnrichment.test.js, which fails when
the committed list differs from what it writes. Its table of websites now gives
FLOKI and TURBO a URL. make enrich-tokens and make apply-enrichment run the
programs. script/check-censored allows the MUSD token's on-chain name in the
data file.

Model: opus-5-5
2026-10-14 20:10:30 +02:00
clawbot 575f1658ae fix: estimate a request even when the site sends its gas limit (closes #554)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
ethers asks the node for a gas estimate only when the request has no gas
limit, and that estimate is the wallet's only check that a transaction
does not revert. Since a site's `gas` became the gas limit, a reverting
request carrying one opened the approval window and could be signed and
mined to fail. Such a request is now estimated first, with the site's
limit and under the same timeout; a failure goes back to the site with no
window, and the site's gas limit is still the one shown and signed. A
limit too low for the call now fails the same way.

Model: opus-5-5
2026-10-14 19:46:01 +02:00
clawbot c40c7432a5 fix: sign and export with each address's own derivation index (closes #510)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Sending, site transactions, site signatures and private key export
derived the key at the address's position in the wallet's list. After
an import whose scan passed over an unused index, or after removing an
address that was not the last, that position named another address's
key.

Each HD and xprv address record now stores the index it was derived at
(at wallet creation, by the import scan, by "+"). getSignerForAddress()
derives at that index, refuses a record without one, and refuses any key
whose address differs from the record's. The confirmation screen signs
as the From address it shows, not the selected one. The export screen
reports a refusal instead of calling the password wrong.

Model: opus-5-5
2026-10-14 18:22:12 +02:00
clawbot 8e20a4e21c fix: the connection prompt shows the shared address whole (closes #517)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The element holding the address on the connection prompt was a flex row,
so the title line and the address that renderAddressHtml() returns sat
side by side and the address got only the width left over, which hid its
last nine characters. It is now a plain block, as on the signature
screen, so the address has a row of its own. The Chrome browser suite's
connection test now measures that row with the check the other screens
already use.

Model: opus-5-5
2026-10-14 17:11:34 +02:00
clawbot f283a5bb91 fix: the typed data screen shows only the message fields that are signed (closes #539)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
ethers signs only the fields a type declares and drops any other key, but
the Message section listed every key the site sent, so an added key read
as signed. The Message section now follows the signed type, at every level
of nesting and in the type's order. Typed data with other keys is not
refused: those keys are listed after the message under "Not part of the
signature", by their place in the message. README SignApproval says so.
The operator/spender test now also checks the Message section, and new
tests cover an added key in a nested struct and in a list item.

Model: opus-5-5
2026-10-14 16:41:13 +02:00
clawbot cb27281b21 fix: the transaction detail screen decodes a token transfer against the token contract (closes #515)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The screen decoded a transaction's data against the history entry's `to`,
which for a token transfer row is the transfer's recipient. The decoded
Token line named the recipient, and the amount read "decimals unknown" for
a token the wallet tracks. It now decodes against the address the
explorer's answer for that transaction says it was sent to, which is the
right address for every kind of row. tests/tokenTransferDetail.test.js
opens a row made by the real history parser and checks the Token,
Recipient and Amount lines.

Model: opus-5-5
2026-10-14 16:37:55 +02:00
clawbot 00d1fecc36 fix: eth_sign's warning and the README say what it signs (closes #409)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
eth_sign is signed as an EIP-191 personal message over its data, which is
what the Ethereum JSON-RPC specification defines it to return. Its warning
on the signature prompt said the site was asking to sign a raw hash that
could drain funds, and the README said the same; neither was true. The
warning now says the site used the older eth_sign method and that the data
will be signed as a personal message, and the README's SignApproval section
says so too. Tests pin that the background reads eth_sign address first and
data second, and that the sign screen answers it with the personal message
signature over the data.

Model: opus-5-5
2026-10-14 15:41:22 +02:00
clawbot dfc3a35fe3 fix: Sign & Send refuses a transfer reviewed on another network (closes #524)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Settings is reachable from the confirmation screen, and Back from it only
unhides the screen as it was drawn. Sign & Send took the network at the
click, so a transfer reviewed on Sepolia went out on mainnet after a switch
in between. show() now records the chain id of the network it draws for.
Sign & Send refuses with a message on the screen when the active network
differs, and the send carries that chain id, so ethers will not populate it
for a provider on any other network.

Model: opus-5-5
2026-10-14 15:31:50 +02:00
clawbot 2442441830 fix: use the gas limit a site sends as gas (closes #207)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Sites send the gas limit under its JSON-RPC name, `gas`. ethers only
reads `gasLimit`, so the field was dropped and the node's estimate was
shown and signed instead. The page's request now takes `gas` as the gas
limit, and the existing ceiling, display and comparison with the signed
transaction cover it as they already did `gasLimit`. A request that
gives both with different values is refused.

Model: opus-5-5
2026-10-14 13:48:52 +02:00
clawbot f1a13a50a6 fix: the defective xprv wallet notice names the keys that still reach its addresses (closes #255)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The notice told the user that these addresses are reachable by software
that repeats the same derivation, while the same wallet blocks Export
Private Key, so there was no way to take that route from here. The
second paragraph now names private key export among the disabled
controls and says the addresses can be reached outside this wallet with
the extended private key the user imported, if they kept a copy of it,
or with the master extended private key or recovery phrase it came
from. No behaviour changes. A test checks the new wording.

Model: opus-5-5
2026-10-14 11:48:46 +02:00
clawbot b4059af827 fix: a listed site ending in a dot gets the phishing warning (closes #534)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The blocklist check hashed the hostname as the page gave it, so
"myetheywallet.com." and every subdomain written that way hashed
differently from the listed "myetheywallet.com" and opened approval
prompts with no warning. The browser and DNS treat both spellings as
one host. hostnameVariants() now drops one trailing dot before
building the hostname and its parent domains, so the check sees the
name the list holds.

Model: opus-5-5
2026-10-14 10:48:41 +02:00
clawbot 8c08b3d467 docs: say what a closed window does to a tx or sign approval (closes #505)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The approval port's disconnect handler leaves a transaction or signing
approval pending, and its comments said that was so the user could reopen
the toolbar popup. Those approvals open in their own window. When that
window closes, the windows.onRemoved listener rejects the approval, unless
an approved attempt is still running, in which case that attempt answers
the page. Both comments now say so. No code changed.

Model: opus-5-5
2026-10-14 09:48:41 +02:00
clawbot fd5921f62e fix: make fmt refuses to run without the repo's own prettier (closes #509)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
In a clone where script/bootstrap has not run, node_modules/ is empty and
yarn falls back to whatever prettier is on the PATH, which can format files
differently from the version yarn.lock pins. script/fmt and script/fmt-check
now stop with a message telling the user to run script/bootstrap when
node_modules/.bin/prettier is missing. After bootstrap they behave as
before. The README Entrypoints entries say so.

Model: opus-5-5
2026-10-14 09:31:47 +02:00
clawbot 83f3549d37 fix: typed data for another chain cannot be signed (closes #519)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
EIP-712 has a wallet refuse typed data whose domain chainId is not the
active chain, because the signature is valid on the chain it names. The
sign screen now shows such a request with the reason on its error line
and Sign disabled, the same way it refuses a mismatched primary type.
The background checks again when the signature comes back, against the
network active then, and answers the site with that refusal instead of
the signature. A chain id is read as ethers signs it, so 1, "1" and
"0x1" are one chain. Typed data with no chainId signs as before.

Model: opus-5-5
2026-10-14 08:31:44 +02:00
clawbot eb012b9eb3 fix: Chrome draws the popup's text at the 14px text-sm declares (closes #456)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Chrome gives extension pages a stylesheet of its own that sets body to
75%, outside any cascade layer, so it beat Tailwind's text-sm and Chrome
drew the popup at 12px where Firefox draws 14px. body now carries
text-sm!, the important form, as it already carries font-mono!. Both
browser suites check that body computes to 14px.

At 14px the recovery phrase instruction on the add wallet screen takes a
third line, which pushed Import below the fold at 360x600. The die button
now shares the instruction's row instead of taking a row of its own.

Model: opus-5-5
2026-10-14 08:02:14 +02:00
clawbot adff1e0b9d docs: a change adds no Completed Steps entry to TODO.md (closes #222)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Every change used to add an entry at the top of Completed Steps in its
own commit, so each merge into `next` left every other open PR
conflicting on that list. The Workflow section now says to add no
entry: what landed is recorded on the tracker and in `git log`, where
each commit title names the issue it closes. The step that moved Next
Step into Completed Steps and pulled the top of Future Steps into Next
Step goes with it. The entries already in Completed Steps stay.

Model: opus-5-5
2026-10-14 05:15:05 +02:00
clawbot 8e52528f8b test: wait until the page shows a screen, not only until it lays out (closes #502)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
The browser suites' wait for a screen passed as soon as the element laid
out. Until the page's stylesheet has applied, every view lays out, so an
approval test could read the prompt before the page's script had filled
it. visible() in the Chrome harness and waitVisible() in the Firefox
driver 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()
keeps on every view but the current one. Both also require the element's
computed visibility to be visible, as the browser library's own check in
Chrome did. No caller changed.

Model: opus-5-5
2026-10-08 12:49:43 +02:00
clawbot b8b13ef5cd fix: switching the network in Settings tells open pages (closes #500)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
Once Settings has saved the new network it asks the background to send
chainChanged with the new chain id to every open tab, through the same
function an approved site switch request uses. Only the extension's own
pages can ask for that. Choosing the network already active changes
nothing and sends nothing.

Model: opus-5-5
2026-10-08 09:13:16 +02:00
clawbot ff05bd50f7 fix: only the user switches the wallet's network (closes #408)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
A connected site's wallet_switchEthereumChain request for the other
supported network now opens a prompt in its own window, through the
existing approval machinery, naming the site and both networks. The
network, endpoints, balances and 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; a request for the
active network needs none. The approval window no longer shows the
connection prompt while it waits for the approval's description,
since both prompts answer on the same port.

Model: opus-5-5
2026-10-08 07:30:16 +02:00
clawbot ca18beb97f docs: put Sepolia in scope and write down the provider flag exception (closes #165)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
The README's 1.0 non-goal said Ethereum mainnet only, while Sepolia
ships as a selectable network. It now puts mainnet and Sepolia 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. It is now written down
as an exception to the rule against naming competitors, in the README's
Policies section and in RULES.md, and the comment beside it says so.
Neither document spells the name, so script/check-censored, which
already allows the flag only in src/content/inpage.js and its built
copies, is unchanged.

Model: opus-5-5
2026-10-07 23:58:49 +02:00
clawbot bb60b399ec test: tighten two checks in the persisted-field harness (closes #379)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
The polarity check now counts only `hostile` and `falsy` values, not a
`hostileRestore` value, which reaches only the views its entry names.
The stale index 5 moves into `hostile` for `selectedWallet` and
`selectedAddress`, as each field's one value still truthy after the
floor.

Each `hostileRestore` entry declares whether its boot lands on its view
or falls back to Home, and the test checks the one view on screen. A
value driven onto every restorable view lists only the views it falls
back on, so a view added later is driven by default.

The header and the README restate the remaining limits as built and say
which boots are held to where the popup lands.

Model: opus-5-5
2026-10-07 11:59:14 +02:00
clawbot 447d714313 fix: show every password error in its own fixed-height line (closes #493)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
The add wallet screen reported a missing, short or mismatched password in
the flash line at the top of the popup, and the private key export,
recovery phrase and delete wallet screens each wrote to a line of their own
above the password field. All four now use showError() and hideError() with
a fixed-height error line below the field, as the send confirmation and
approval screens do. On the add wallet screen the line sits beside the
Import button, which keeps its place at 360x600. The line clears when the
screen is shown again and when the password is tried again. Other add
wallet messages stay in the flash line.

Model: opus-5-5
2026-10-07 10:59:16 +02:00
62 changed files with 5839 additions and 364 deletions
+15 -1
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 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 icons enrich-tokens apply-enrichment clean dev
# Standard targets are thin shims; the implementations live in script/
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
@@ -111,6 +111,20 @@ vendor-blocklist:
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 list differs from what this
# writes, or while an address in the data or in the script's table of websites
# matches no entry in the list.
apply-enrichment:
@node script/lib/apply-enrichment.js
clean:
@rm -rf dist/ release/
+171 -41
View File
@@ -239,18 +239,24 @@ provide:
`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
not a result, so docker is required to lint.
- `script/fmt` — format all files (writes), on the host
- `script/fmt-check` — check formatting (read-only), on the host
- `script/fmt` — format all files (writes), on the host, with the `prettier`
that `script/bootstrap` installs in `node_modules/`; when that one is missing
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-censored` — assert the competitor name RULES.md bars appears
nowhere in the working tree or under `dist/` outside its documented
exceptions: the pinned source reference in `script/vendor-blocklist`, the two
provider-shim identifiers in `src/content/inpage.js`, and one ERC-20's
on-chain name in `src/shared/tokenList.js`. Each is scoped to that path and
fails anywhere else. Run by the `lint` phase, so part of `make check`; it
inspects `dist/` when there is one and says loudly when there is not, and the
build context of that phase never has one. `make build` re-runs it with
`--require-dist`, so a build artifact is always covered
on-chain name in `src/shared/tokenList.js` and in
`script/lib/token-enrichment.json`, the fetched data that file's names are
written from. Each is scoped to the paths named and fails anywhere else. Run
by the `lint` phase, so part of `make check`; it inspects `dist/` when there
is one and says loudly when there is not, and the build context of that phase
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
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,
@@ -322,6 +328,19 @@ The Makefile shims to those. It also carries a few targets that have no
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 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/`
@@ -343,6 +362,14 @@ are outside `make check`. Neither opens the popup from the toolbar button: both
load its page in an ordinary tab, so what the toolbar popup itself adds, its
size and its closing when it loses focus, is covered by neither.
Each run builds its image under a tag of its own, `autistmask-e2e-chrome:` or
`autistmask-e2e-firefox:` followed by twelve random hex digits, so two clones
can run the same suite at once without one build taking the other's image away.
The run removes that image, and its container if one is still running, after the
suite passes or fails, on Ctrl-C, on `kill`, and when the terminal or SSH
session it runs in closes. A run killed outright (`kill -9`) cannot clean up:
its image stays under its tag until removed by hand with `docker image rm`.
### Chrome (`make test-e2e`)
`make test-e2e` builds `dist/chrome/` and drives the **real popup in a real
@@ -414,17 +441,20 @@ content script, the inpage provider, the background worker and the approval
popup all have to work together. A local test page is served by the route
handler on a reserved-TLD origin, gets `window.ethereum` from the shipped
`MAIN`-world content script like any other page, and drives
`eth_requestAccounts`, `personal_sign`, `eth_signTypedData_v4` and
`eth_sendTransaction` through the real prompts. Every signature is recovered in
the runner and compared against the active address, the transaction assertions
run against the raw signed transaction captured at `eth_sendRawTransaction`
rather than against anything the extension reported, rejecting each prompt is
required to return a rejection to the page rather than hang or resolve, a prompt
raised while another approval window has focus is required to open a window of
its own, and the password is required to be absent from every message the
approval window sends to the background — with the message that would carry it
required to be present, so that check cannot pass by observing nothing. That
last one is the standing floor under
`eth_requestAccounts`, `personal_sign`, `eth_signTypedData_v4`,
`eth_sendTransaction` and `wallet_switchEthereumChain` through the real prompts.
Every signature is recovered in the runner and compared against the active
address, the transaction assertions run against the raw signed transaction
captured at `eth_sendRawTransaction` rather than against anything the extension
reported, rejecting each prompt is required to return a rejection to the page
rather than hang or resolve, a network switch request is required to leave the
stored network unchanged and send no `chainChanged` until it is approved, a
network switch in Settings is required to send the page `chainChanged` with the
new chain id, a prompt raised while another approval window has focus is
required to open a window of its own, and the password is required to be absent
from every message the approval window sends to the background — with the
message that would carry it required to be present, so that check cannot pass by
observing nothing. That last one is the standing floor under
[#157](https://git.eeqj.de/sneak/AutistMask/issues/157).
The limits of that coverage and of the rest of the Chrome suite, none of them
@@ -525,10 +555,11 @@ 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
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
suite), wallet creation through the UI, the Add Token screen, and the four dApp
round trips — `eth_requestAccounts`, `personal_sign`, `eth_sendTransaction`, and
a closed approval window rejecting with EIP-1193 4001 — driven through the real
content script, background page and approval windows.
suite), wallet creation through the UI, the Add Token screen, and the dApp round
trips — `eth_requestAccounts`, `personal_sign`, `wallet_switchEthereumChain`
rejected and then approved, `eth_sendTransaction`, and a closed approval window
rejecting with EIP-1193 4001 — driven through the real content script,
background page and approval windows.
The suite lives in `tests/e2e/firefox/`. Its WebDriver client (`driver.js`) has
**no npm dependencies at all**: it is built on global `fetch` and
@@ -1184,7 +1215,16 @@ The core hierarchy is **Wallets → Addresses**:
of its stored `xpub` by `src/shared/walletDefects.js`, explained in the
wallet list, and blocked from signing, sending and private-key export. It
is never deleted or rewritten.
- An **address** holds ETH and ERC-20 tokens.
- An **address** holds ETH and ERC-20 tokens. In an HD or xprv wallet each
address record also stores `index`, the derivation index it was made at: 0 for
the first address, the index the import scan found it at, or `nextIndex` when
"+" added it. An address's `index` and its position in the wallet's list
differ once the scan has passed over an unused index or an address has been
removed. Sending, site transactions, site signatures and private-key export
derive the key at `index`, and refuse it unless it is that address's own key;
an HD or xprv address with no valid `index` is refused before anything is
derived (`getSignerForAddress()` in `src/shared/wallet.js`). Each refusal is a
full sentence naming the address.
- The user can have multiple wallets, each with multiple addresses (HD) or a
single address (key).
@@ -1451,14 +1491,17 @@ view would leave a wallet one click from deletion.
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
cannot move the password fields under the pointer.
- "Import" button
- "Import" button, with the error line beside it so that it adds no height
to a screen whose button already starts near the bottom of the popup
- **Transitions**:
- "Import" with a valid entry and a matching password of at least 12
characters → creates the wallet, clears the navigation stack, and →
**Home**. The phrase and xprv modes then scan for further used addresses
and report the count as a flash message.
- "Import" with an invalid entry, a duplicate wallet or address, or a short
or mismatched password → flash message, no screen change
- "Import" with a missing, short or mismatched password → full-sentence
error on the error line, 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)
#### AddressDetail (`address`)
@@ -1497,8 +1540,8 @@ view would leave a wallet one click from deletion.
copy)
- Warning that anyone holding the private key can transfer all funds from
the address
- Error line
- Password input and "Reveal" button, shown until the key is revealed
- Password input, error line and "Reveal" button, shown until the key is
revealed
- The private key on a highlighted background, tap to copy, shown only after
the password has been accepted
- **Transitions**:
@@ -1507,6 +1550,9 @@ view would leave a wallet one click from deletion.
change)
- "Reveal" (wrong password) → full-sentence error on the error line, nothing
revealed (no screen change)
- "Reveal" (correct password, but this address's key is refused, see Data
Model) → full-sentence error saying why, nothing revealed (no screen
change)
- "Back" → previous screen (AddressDetail)
- Settings gear → **Settings**, whose "Back" goes to AddressDetail: leaving
drops the address the screen was showing, so it also takes the screen off
@@ -1634,8 +1680,21 @@ view would leave a wallet one click from deletion.
the contract's own answer is read at signing time only to be compared with
it, and a disagreement is a refusal rather than a preference for either
value (`src/shared/transferAmount.js`)
- "Sign & Send" after the active network was switched in Settings while this
screen was open → nothing is signed; "The active network changed after
this transaction was reviewed, so it was not sent. Please go back and
review it again." in the errors box, no screen change. The transaction
carries the chain id of the network the screen was shown for, so it cannot
be signed for another
- "Sign & Send" (wrong password) → "Wrong password." on the password error
line, no screen change
- "Sign & Send" signs as the From address above, not as whichever address is
selected: the two are saved separately, so the popup can reopen onto this
screen with another address selected. When the selected wallet does not
hold the From address → full-sentence error on the password error line,
nothing sent (no screen change)
- "Sign & Send" (correct password, but the From address's key is refused,
see Data Model) → nothing is signed → **ErrorTx** saying why
- "Back" → **Send**
#### WaitTx (`wait-tx`)
@@ -1779,8 +1838,12 @@ view would leave a wallet one click from deletion.
plus a "+ Add token" button
- Display: "Show tracked tokens with zero balance" checkbox, "UTC
Timestamps" checkbox, and a Theme selector (System / Light / Dark)
- Network: network selector (Ethereum Mainnet / Sepolia Testnet); switching
resets the RPC and Blockscout endpoints to that network's defaults
- Network: network selector (Ethereum Mainnet / Sepolia Testnet). The
network changes only here, or when the user approves a site's request on
**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
`eth_chainId` before being saved)
- Blockscout API: endpoint URL input + "Save" button (validated against
@@ -1836,8 +1899,8 @@ view would leave a wallet one click from deletion.
- Wallet name
- Warning box stating that anyone holding these words can take everything in
the wallet, from any device, without the password
- Error line
- Password input + "Reveal" button, shown until the password is accepted
- Password input, error line and "Reveal" button, shown until the password
is accepted
- The recovery phrase itself, in full and click-to-copy, shown only after a
correct password and in place of the password prompt
- **Transitions**:
@@ -1866,8 +1929,8 @@ view would leave a wallet one click from deletion.
- "Back" button, "Delete Wallet" heading
- Warning naming the wallet and stating that deletion is permanent and any
funds are unrecoverable without the recovery phrase
- Error line
- Password input
- Error line
- "Confirm Delete" button
- An underlined "I have lost my password" control
- **Transitions**:
@@ -2060,7 +2123,11 @@ view would leave a wallet one click from deletion.
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
cannot replace one of the user's pending transactions or leave this one stuck
behind a gap. A request that cannot be populated — unreachable node, reverting
behind a gap. A gas limit the site supplies, as `gas` or `gasLimit`, is the
one shown and signed in place of the node's estimate, but the node still
estimates the transaction with that limit, as the check that it neither
reverts nor runs out of gas; a request that gives 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
transaction approval exists at a time: populating fixes the nonce, so a second
`eth_sendTransaction` arriving while one is unanswered is refused with
@@ -2068,7 +2135,10 @@ 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
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
position, the browser picks the position.
position, the browser picks the position. The network shown is the one the
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**:
- "Transaction Request" heading
- Phishing warning banner (shown when the hostname is on the phishing
@@ -2092,6 +2162,9 @@ view would leave a wallet one click from deletion.
shown, exactly as shown, hands the signed transaction to the background to
broadcast, then → **WaitTx** in the same popup window
- "Confirm" (wrong password) → error line, no screen change
- "Confirm" (correct password, but the From address's key is refused, see
Data Model) → full-sentence error on the error line saying why, nothing
signed (no screen change)
- "Reject" → closes popup (returns rejection to background)
- Popup window closed without answering → the request is rejected with
EIP-1193 code 4001
@@ -2110,7 +2183,12 @@ view would leave a wallet one click from deletion.
blocklist)
- Site origin (bold, scheme and port included) + "wants you to sign a
message"
- Danger warning box (shown for `eth_sign`, which signs a raw hash)
- Warning box, shown for `eth_sign`: "This site used the older eth_sign
method. The data will be signed as a personal message." An `eth_sign`
request names the address first and the data second. Its data is signed
exactly as a `personal_sign` message is, as an EIP-191 personal message,
which is what the Ethereum JSON-RPC specification defines `eth_sign` to
return.
- Type: "Personal message" or "Typed data (EIP-712)"
- From: color dot + full address + etherscan link
- Message: for `personal_sign` and `eth_sign`, the text the message's bytes
@@ -2125,7 +2203,12 @@ view would leave a wallet one click from deletion.
Bytes that are not UTF-8 are shown as "This message is not text." For
typed data, formatted domain/type/message fields (EIP-712). The primary
type shown is the one ethers signs, derived from the typed data's `types`,
not the type the site states.
not the type the site states. The message shows only the fields that type
declares, at every level of nesting and in the type's order: ethers signs
those and drops any other key. Typed data carrying other keys is not
refused; each such key is listed after the message under "Not part of the
signature", by its place in the message (`details.note`,
`details[1].note`) and its value.
- Token permission warning, at the top of the message (typed data whose
primary type is `Permit`, as in EIP-2612, or one of Permit2's signature
types): "⚠️ TOKEN PERMISSION: Signing this lets the spender below take the
@@ -2146,6 +2229,13 @@ view would leave a wallet one click from deletion.
- 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
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
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
@@ -2159,6 +2249,40 @@ view would leave a wallet one click from deletion.
- Popup window closed without answering → the request is rejected with
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`)
- **When**: the stored profile fails `assertStateUsable()`. At open, that is
@@ -2453,6 +2577,8 @@ logged.
- Sign transactions requested by connected sites (`eth_sendTransaction`)
- Sign messages (`personal_sign`, `eth_sign`)
- 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)
- ETH/USD and token/USD price display
- Configurable RPC endpoint and Blockscout API
@@ -2602,7 +2728,8 @@ plaintext list of anyone's domain names.
When a dApp on a blocklisted domain requests a wallet connection, transaction
approval, or signature, the approval popup displays a prominent red warning
banner alerting the user. The domain checker matches exact hostnames and all
parent domains (subdomain matching).
parent domains (subdomain matching), and checks a hostname written with a
trailing dot (`name.`) as `name`.
#### Transaction Decoding
@@ -2645,7 +2772,7 @@ Currently supported:
### Non-Goals for 1.0
- Multi-chain support (Ethereum mainnet only)
- Chains other than Ethereum mainnet and the Sepolia testnet
- Hardware wallet support
## TODO
@@ -2679,7 +2806,10 @@ Currently supported:
## Policies
- We don't mention "the other wallet" by name in code or documentation. We're
our own thing.
our own thing. Written exception: the injected provider in
`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
it gets big.
@@ -2709,7 +2839,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.
`script/check-censored` reads the name back out of that one file and fails the
build wherever else it appears, save for three shipped-code literals it cannot
avoid — each permitted only at the one path that carries it, and listed in that
avoid — each permitted only at the paths that carry it, and listed in that
script's header.
## Author
+4 -1
View File
@@ -118,4 +118,7 @@ contradicts either, the originals govern.
- [ ] "Address" not "account" or "derived key"
- [ ] "Password" not "encryption key" or "vault passphrase"
- [ ] Error messages are full sentences
- [ ] No competitor mentioned by name in code or documentation
- [ ] No competitor mentioned by name in code or documentation. Written
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)
+59 -4
View File
@@ -2,10 +2,9 @@
- `git pull` `next` and cut a branch from it — one branch per issue, named
`issue-<N>-<slug>`. Never branch from `main`.
- Do the work as one commit whose title ends with ` (closes #N)`, with the
`TODO.md` update in that same commit.
- Move Next Step to the top of Completed Steps; move the top item of Future
Steps into Next Step.
- Do the work as one commit whose title ends with ` (closes #N)`.
- Add no entry to Completed Steps. What landed is recorded on the tracker and in
`git log`, where each commit title names the issue it closes.
- Run `make fmt`, then `make check`. A feature branch may be red; `next` and
`main` may not.
- Rebase onto current `next` immediately before pushing — other branches land on
@@ -44,6 +43,49 @@ then continue tagging as milestones land.
# 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
@@ -58,6 +100,19 @@ then continue tagging as milestones land.
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
+4 -2
View File
@@ -18,6 +18,8 @@
# (src/shared/balances.js), and this contract's on-chain name is that
# string, so censoring the repo cannot stop the wallet displaying it.
# Dropping the entry instead would cost the user MUSD spoof detection.
# 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
# literals rather than whole files, so they are enforced by counting, and each
@@ -102,8 +104,8 @@ allowed_literals_for() {
src/content/inpage.js | dist/*/src/content/inpage.js)
printf 'is%s\n_%s\n' "$NAME" "$NAME" >"$ALLOWED_LITERALS_FILE"
;;
src/shared/tokenList.js | dist/*/src/background/index.js | \
dist/*/src/popup/index.js)
src/shared/tokenList.js | script/lib/token-enrichment.json | \
dist/*/src/background/index.js | dist/*/src/popup/index.js)
printf '%s USD\n' "$NAME" >"$ALLOWED_LITERALS_FILE"
;;
esac
+7
View File
@@ -25,6 +25,13 @@ run_yarn() {
main() {
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..."
run_yarn run fmt
}
+7
View File
@@ -26,6 +26,13 @@ run_yarn() {
main() {
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..."
run_yarn run fmt-check
}
+159
View File
@@ -0,0 +1,159 @@
// 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. An entry the data
// does not mention is left as it is. An address in the data or in EXTRA_URLS
// that matches no entry in tokenList.js, capitals included, is an error naming
// that address. 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, each spelled as tokenList.js spells it. A
// token gets its URL here only where the fetched data has none, so an address
// the data has a URL for is never used.
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",
"0x5A98FcBEA516Cf06857215779Fd812CA3beF1B32": "https://lido.fi",
"0xC18360217D8F7Ab5e7c516566761Ea12Ce7F9D72": "https://ens.domains",
"0xD533a949740bb3306d119CC777fa900bA034cd52": "https://curve.fi",
"0x111111111117dC0aa78b770fA6A738034120C302": "https://1inch.io",
"0x163f8C2467924be0ae7B5347228CABF260318753": "https://worldcoin.org",
"0xC011a73ee8576Fb46F5E1c5751cA3B9Fe0af2a6F": "https://synthetix.io",
"0xBBbbCA6A901c926F240b89EacB641d8Aec7AEafD": "https://loopring.org",
"0xC669928185DbCE49d2230CC9B0979BE6DC797957": "https://bitstable.finance",
"0xfAbA6f8e4a5E8Ab82F62fe7C39859FA577269BE3": "https://ondo.finance",
"0x6De037ef9aD2725EB40118Bb1702EBb27e4Aeb24": "https://www.render.com",
"0x4a220E6096B25EADb88358cb44068A3248254675": "https://www.quant.network",
"0xba5BDe662c17e2aDFF1075610382B9B691296350": "https://superrare.com",
"0xc00e94Cb662C3520282E6f5717214004A7f26888": "https://compound.finance",
"0xE41d2489571d322189246DaFA5ebDe1F4699F498": "https://0x.org",
"0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e": "https://yearn.fi",
"0x6fB3e0A217407EFFf7Ca062D46c26E5d60a14d69": "https://iotex.io",
"0xF57e7e7C23978C3cAEC3C3548E3D615c346e79fF": "https://immutable.com",
"0x0F5D2fB29fb7d3CFeE444a200298f468908cC942": "https://decentraland.org",
"0x3845badAde8e6dFF049820680d1F14bD3903a5d0": "https://www.sandbox.game",
"0x6810e776880C02933D47DB1b9fc05908e5386b96": "https://gnosis.io",
"0x5f98805A4E8be255a32880FDeC7F6728C6568bA0": "https://www.liquity.org",
"0x0D8775F648430679A709E98d2b0Cb6250d2887EF":
"https://basicattentiontoken.org",
"0xc944E90C64B2c07662A292be6244BDf05Cda44a7": "https://thegraph.com",
"0x853d955aCEf822Db058eb8505911ED77F175b99e": "https://frax.finance",
"0x0000000000085d4780B73119b644AE5ecd22b376": "https://www.trueusd.com",
"0x3432B6A60D23Ca0dFCa7761B7ab56459D9C964D0": "https://frax.finance",
"0xf939E0A03FB07F59A73314E73794Be0E57ac1b4E": "https://curve.fi",
"0x808507121B80c02388fAd14726482e061B8da827": "https://pendle.finance",
"0xCa14007Eff0dB1f8135f4C25B34De49AB0d42766": "https://starknet.io",
"0x8457CA5040ad67fdebbCC8EdCE889A335Bc0fbFB": "https://alt.layer",
"0xB50721BCf8d664c30412Cfbc6cf7a15145234ad1": "https://arbitrum.io",
"0x6985884C4392D348587B19cb9eAAf157F13271cd": "https://layerzero.network",
"0xFe0c30065B384F05761f15d0CC899D4F9F9Cc0eB": "https://ethfi.org",
"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",
"0xA35923162C49cF95e6BF26623385eb431ad920D3": "https://www.turbo-eth.com",
"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) {
for (const addr of Object.keys(EXTRA_URLS)) {
if (!tokenList.includes(`address: "${addr}"`)) {
throw new Error(
`No entry in src/shared/tokenList.js has the address ${addr} from EXTRA_URLS.`,
);
}
}
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) {
throw new Error(
`No entry in src/shared/tokenList.js matches the address ${addr} from script/lib/token-enrichment.json.`,
);
}
// 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
@@ -0,0 +1,115 @@
// 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);
});
File diff suppressed because it is too large Load Diff
+28 -16
View File
@@ -22,13 +22,29 @@ set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
IMAGE="$("$SCRIPT_DIR/projectname")-e2e-chrome"
IIDFILE=""
# Each run builds, runs and then removes an image under a tag of its own.
# Under one tag shared by every run, a second clone's build moves the tag
# off this run's image, and the image store can then delete that untagged
# image before or while this run uses it. The tag is random rather than
# $$ because CI jobs share one docker daemon, but each job's container
# numbers its processes from scratch.
SUFFIX="$(od -An -N6 -tx1 /dev/urandom | tr -d ' \n')"
NAME="$("$SCRIPT_DIR/projectname")-e2e-chrome"
IMAGE="$NAME:$SUFFIX"
# Both docker runs below use this container name: docker run --rm returns
# only once its container is removed, so the second never meets the first.
CONTAINER="$NAME-$SUFFIX"
cleanup() {
if [ -n "$IIDFILE" ]; then
rm -f "$IIDFILE"
# After a third Ctrl-C docker's client exits whether or not the
# container has stopped, and a container still running keeps the image
# in use.
if docker container inspect "$CONTAINER" >/dev/null 2>&1; then
docker container rm -f "$CONTAINER" >/dev/null
fi
# No image to remove when the build failed or was interrupted.
if docker image inspect "$IMAGE" >/dev/null 2>&1; then
docker image rm "$IMAGE" >/dev/null
fi
}
@@ -40,22 +56,16 @@ main() {
exit 1
fi
IIDFILE="$(mktemp)"
trap cleanup EXIT
trap 'cleanup; exit 130' INT TERM
# HUP: the terminal or SSH session running the suite closed.
trap 'cleanup; exit 130' HUP INT TERM
echo "Building the Chrome e2e image (extension included)..."
# --no-cache: the image build runs make build and its checks, and a
# cached layer is a check that did not run.
docker build --no-cache --iidfile "$IIDFILE" -t "$IMAGE" \
-f tests/e2e/Dockerfile .
docker build --no-cache -t "$IMAGE" -f tests/e2e/Dockerfile .
echo "Running e2e suite in the pinned Playwright container..."
# The image is run by ID, not by tag: where two clones of this repo run
# the suite at once, the other build can move the tag between this
# build and this run, and the suite would then silently test the other
# checkout.
#
# --ipc=host: Chromium's shared-memory needs more than the default
# 64MB /dev/shm or renderers crash.
# HOME=/tmp: the image's root home is not a reliable place for the
@@ -75,11 +85,12 @@ main() {
# probe. The image is pinned by digest, so this can only ever bite
# on a deliberate bump.
docker run --rm \
--name "$CONTAINER" \
--ipc=host \
-e HOME=/tmp \
-e PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1 \
-e "E2E_TRACE_NETWORK=${E2E_TRACE_NETWORK:-0}" \
"$(cat "$IIDFILE")" \
"$IMAGE" \
node tests/e2e/run.js
# Where chrome.storage.local lives, and what moves it: two unpacked loads
@@ -93,9 +104,10 @@ main() {
# --host-resolver-rules=MAP * ~NOTFOUND so nothing it does can leave.
echo "Running the extension-id and storage-partition observations..."
docker run --rm \
--name "$CONTAINER" \
--ipc=host \
-e HOME=/tmp \
"$(cat "$IIDFILE")" \
"$IMAGE" \
node tests/e2e/storagePartition.js
}
+22 -16
View File
@@ -21,13 +21,23 @@ set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
IMAGE="$("$SCRIPT_DIR/projectname")-e2e-firefox"
IIDFILE=""
# A tag and a container name of this run's own, as in script/test-e2e,
# which says why.
SUFFIX="$(od -An -N6 -tx1 /dev/urandom | tr -d ' \n')"
NAME="$("$SCRIPT_DIR/projectname")-e2e-firefox"
IMAGE="$NAME:$SUFFIX"
CONTAINER="$NAME-$SUFFIX"
cleanup() {
if [ -n "$IIDFILE" ]; then
rm -f "$IIDFILE"
# After a third Ctrl-C docker's client exits whether or not the
# container has stopped, and a container still running keeps the image
# in use.
if docker container inspect "$CONTAINER" >/dev/null 2>&1; then
docker container rm -f "$CONTAINER" >/dev/null
fi
# No image to remove when the build failed or was interrupted.
if docker image inspect "$IMAGE" >/dev/null 2>&1; then
docker image rm "$IMAGE" >/dev/null
fi
}
@@ -39,22 +49,16 @@ main() {
exit 1
fi
IIDFILE="$(mktemp)"
trap cleanup EXIT
trap 'cleanup; exit 130' INT TERM
# HUP: the terminal or SSH session running the suite closed.
trap 'cleanup; exit 130' HUP INT TERM
echo "Building the pinned Firefox e2e image (extension included)..."
# --no-cache: the image build runs make build and its checks, and a
# cached layer is a check that did not run.
docker build --no-cache --iidfile "$IIDFILE" -t "$IMAGE" \
-f tests/e2e/firefox/Dockerfile .
docker build --no-cache -t "$IMAGE" -f tests/e2e/firefox/Dockerfile .
echo "Running the Firefox e2e suite..."
# The image is run by ID, not by tag: where two clones of this repo run
# the suite at once, the other build can move the tag between this
# build and this run, and the suite would then silently test the other
# checkout.
#
# --shm-size=1g: Firefox needs more than the default 64MB /dev/shm.
# --network none: the suite stubs nothing, so this is what keeps the
# run offline and deterministic. The extension swallows its own
@@ -69,10 +73,11 @@ main() {
# "CanCreateUserNamespace() clone() failure: EPERM" on startup here;
# it is cosmetic and headless Firefox runs fine without it.
docker run --rm \
--name "$CONTAINER" \
--shm-size=1g \
--network none \
-e HOME=/tmp \
"$(cat "$IIDFILE")" \
"$IMAGE" \
node tests/e2e/firefox/run.js dist/firefox
# The install/uninstall/re-install property, against the packaged XPI
@@ -82,10 +87,11 @@ main() {
# and the suite above shares one session throughout.
echo "Running the Firefox re-install suite against the packaged XPI..."
docker run --rm \
--name "$CONTAINER" \
--shm-size=1g \
--network none \
-e HOME=/tmp \
"$(cat "$IIDFILE")" \
"$IMAGE" \
node tests/e2e/firefox/reinstall.js
}
+105 -26
View File
@@ -22,6 +22,7 @@ const { debugFetch, log } = require("../shared/log");
const {
verifySignedTx,
verifySignature,
typedDataChainRefusal,
failureIsRetryable,
describeTxFailure,
sameAddress,
@@ -137,11 +138,12 @@ function releaseTxApprovalSlotFor(approvalId) {
}
}
// One site-connection approval and one sign approval per site at a time: a
// page that asks again before the user has answered is refused with the code
// above instead of opening another window, so it cannot bury the user in
// prompts. The pending approval itself holds the place, so a caller must test
// this and raise its approval with nothing awaited in between.
// One site-connection approval, one sign approval and one network-switch
// approval per site at a time: a page that asks again before the user has
// answered is refused with the code above instead of opening another window,
// so it cannot bury the user in prompts. The pending approval itself holds the
// place, so a caller must test this and raise its approval with nothing awaited
// in between.
function findPendingApproval(origin, type) {
return Object.values(pendingApprovals).find(
(approval) => approval.origin === origin && approval.type === type,
@@ -348,8 +350,9 @@ function settleApproval(id, result, options) {
// What a pending approval resolves to when it is given up on rather than
// answered: the window was closed, or could not be opened at all. A tx or sign
// approval answers the requesting page in EIP-1193 shape; a site-connection
// approval answers the connection handler in its own.
// approval answers the requesting page in EIP-1193 shape; a site-connection or
// network-switch approval answers its handler in the shape the popup's
// decision has, as a refusal.
function abandonedResult(approval, code, message) {
if (approval.type === "tx" || approval.type === "sign") {
return { error: { code, message } };
@@ -588,6 +591,26 @@ 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
// with the page's URL, so this is what separates the popup from the site the
// popup is being asked about.
@@ -597,8 +620,8 @@ function isExtensionSender(sender) {
}
// The approval popup's port: it carries the user's decision on a
// site-connection approval, and its disconnect is how that approval learns the
// popup closed without one.
// site-connection or network-switch approval, and its disconnect is how that
// approval learns the popup closed without one.
//
// The decision travels this port rather than a one-off runtime.sendMessage()
// for exactly one reason: the port is also what the popup's window.close()
@@ -610,8 +633,9 @@ function isExtensionSender(sender) {
// had refused.
//
// TX and sign approvals do not decide here. They stay pending across a
// disconnect — the user can reopen the toolbar popup — and are rejected by the
// windows.onRemoved listener below.
// disconnect. When their window closes, the windows.onRemoved listener below
// rejects them, unless an approved attempt is still running, in which case
// that attempt answers the page.
runtime.onConnect.addListener((port) => {
if (port.name.startsWith("approval:")) {
const id = port.name.split(":")[1];
@@ -639,7 +663,9 @@ runtime.onConnect.addListener((port) => {
const approval = pendingApprovals[id];
if (approval) {
if (approval.type === "tx" || approval.type === "sign") {
// Keep pending — user can reopen the toolbar popup
// When its window closes, windows.onRemoved rejects it,
// unless an approved attempt is still running, in which
// case that attempt answers the page.
return;
}
settleApproval(id, { approved: false, remember: false });
@@ -806,6 +832,10 @@ async function handleRpc(method, params, origin) {
// 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
// [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 activeAddress = activeAddressOf(s);
const allowed = s.allowedSites[activeAddress] || [];
@@ -827,6 +857,27 @@ async function handleRpc(method, params, origin) {
}
if (SUPPORTED_CHAIN_IDS.has(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
// onChainSwitch(), which mutates the singleton and then persists
// every field of it — on an unloaded worker that wrote empty
@@ -937,9 +988,8 @@ async function handleRpc(method, params, origin) {
if (method === "eth_sign") {
signParams.dangerWarning =
"\u26a0\ufe0f DANGER: This site is requesting to sign a raw hash. " +
"This can be used to sign transactions that drain your funds. " +
"Only proceed if you fully understand what you are signing.";
"This site used the older eth_sign method. " +
"The data will be signed as a personal message.";
}
if (findPendingApproval(origin, "sign")) {
@@ -1345,20 +1395,21 @@ startBackgroundJobs();
// which then fails retryably settles instead of waiting in a window that no
// longer exists.
//
// A site-connection approval whose popup connected its port is not decided
// here. That popup approves and closes in the same breath, and this event
// races the decision on a channel of its own — the same race the port exists
// to end. Its port disconnect says the same thing this event does, in an order
// that is defined, so the disconnect is left to say it. The window closing
// before any port connected is the one case with nothing else to speak for it,
// and is rejected here so the dApp is not left waiting on a window that is
// gone.
// A site-connection or network-switch approval whose popup connected its port
// is not decided here. That popup approves and closes in the same breath, and
// this event races the decision on a channel of its own — the same race the
// port exists to end. Its port disconnect says the same thing this event does,
// in an order that is defined, so the disconnect is left to say it. The window
// closing before any port connected is the one case with nothing else to speak
// for it, and is rejected here so the dApp is not left waiting on a window that
// is gone.
if (windowsNs && windowsNs.onRemoved) {
windowsNs.onRemoved.addListener((windowId) => {
for (const [id, approval] of Object.entries(pendingApprovals)) {
if (approval.windowId !== windowId) continue;
const isSite = approval.type !== "tx" && approval.type !== "sign";
if (isSite && approval.portConnected) continue;
const decidedOnPort =
approval.type !== "tx" && approval.type !== "sign";
if (decidedOnPort && approval.portConnected) continue;
const rejection = abandonedResult(
approval,
APPROVAL_REJECTED_CODE,
@@ -1423,6 +1474,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
"AUTISTMASK_ADDRESSES_REMOVED",
"AUTISTMASK_GET_CONNECTED_SITES",
"AUTISTMASK_REMOVE_SITE",
"AUTISTMASK_NETWORK_CHANGED",
];
if (POPUP_ONLY_TYPES.includes(msg.type) && !isExtensionSender(sender)) {
sendResponse({ error: "Unauthorized sender" });
@@ -1446,6 +1498,11 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
resp.signParams = approval.signParams;
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.
resp.isPhishingDomain = isPhishingDomain(
extractHostname(approval.origin),
@@ -1710,7 +1767,8 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
(async () => {
try {
const activeAddress = await getActiveAddress();
const s = await getState();
const activeAddress = activeAddressOf(s);
// Same as the transaction path: the address the approval named
// is the one that must have signed, and a switch since then is
// a refusal rather than a signature from another account.
@@ -1729,6 +1787,20 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
signature,
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 });
sendResponse({ signature });
} catch (e) {
@@ -1801,6 +1873,13 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
broadcastSiteRemoved(msg.origin);
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 };
+4 -1
View File
@@ -99,7 +99,10 @@
const provider = {
isAutistMask: true,
isMetaMask: true, // compatibility — many dApps check this
// An interface-compatibility flag many dApps check. Kept as a written
// exception to the rule that no competitor is named in code: see
// Policies in README.md, and RULES.md.
isMetaMask: true,
chainId: currentChainId,
networkVersion: currentNetworkVersion,
selectedAddress: null,
+91 -32
View File
@@ -7,9 +7,10 @@
<link rel="stylesheet" href="styles.css" />
</head>
<!-- Chrome gives extension pages a stylesheet of its own that sets the
font on body, and a Tailwind class beats it only when marked
important: hence font-mono! rather than font-mono. -->
<body class="bg-bg text-fg font-mono! text-sm">
font and its size on body, and a Tailwind class beats it only when
marked important: hence font-mono! and text-sm! rather than
font-mono and text-sm. -->
<body class="bg-bg text-fg font-mono! text-sm!">
<div id="app" class="p-2 pr-5 overflow-x-hidden">
<!-- ============ GLOBAL TITLE BAR ============ -->
<div
@@ -87,11 +88,14 @@
<!-- Mnemonic form section -->
<div id="add-wallet-section-mnemonic">
<p class="mb-2">
Enter your 12 or 24 word recovery phrase below, or click
the button to roll the die for a new one.
</p>
<div class="mb-1 flex justify-end">
<!-- The die button shares the instruction's row: on a row
of its own it pushes Import below the fold at
360x600. -->
<div class="mb-2 flex justify-between items-start gap-2">
<p>
Enter your 12 or 24 word recovery phrase below, or
click the button to roll the die for a new one.
</p>
<button
id="btn-generate-phrase"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer text-xs"
@@ -207,12 +211,22 @@
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/>
</div>
<button
id="btn-add-wallet-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Import
</button>
<!-- The error line sits beside Import, not above it: at
360x600 the button already starts near the bottom of
the popup, and a line of its own would push it below
the fold. The longest error fits on one line here. -->
<div class="flex items-center gap-2">
<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>
<!-- ============ MAIN VIEW: ALL WALLETS & ADDRESSES ============ -->
@@ -396,10 +410,6 @@
Warning: anyone with this private key can access and
transfer all funds from this address. Never share it.
</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">
<label class="block mb-1">Password</label>
<input
@@ -408,9 +418,13 @@
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
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
id="btn-export-privkey-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mt-2"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Reveal
</button>
@@ -1116,10 +1130,6 @@
<strong id="delete-wallet-name"></strong> is permanent. Any
funds will be unrecoverable without your recovery phrase.
</p>
<div
id="delete-wallet-flash"
class="text-xs text-red-500 mb-2 min-h-[1.25rem] invisible"
></div>
<div class="mb-2">
<label class="block mb-1">Password</label>
<input
@@ -1129,6 +1139,10 @@
placeholder="Enter your password to confirm"
/>
</div>
<div
id="delete-wallet-password-error"
class="text-xs mb-2 min-h-[1.25rem] invisible"
></div>
<button
id="btn-delete-wallet-confirm"
class="border border-border text-red-500 px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
@@ -1273,10 +1287,6 @@
this wallet, from any device, without your password. Never
type them into a website and never show them to anyone.
</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">
<label class="block mb-1">Password</label>
<input
@@ -1285,9 +1295,13 @@
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
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
id="btn-show-phrase-reveal"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mt-2"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Reveal
</button>
@@ -1702,10 +1716,7 @@
<div class="text-xs text-muted mb-1">
Address that will be shared:
</div>
<div
id="approve-address"
class="text-xs flex items-center mb-2"
></div>
<div id="approve-address" class="text-xs mb-2"></div>
</div>
<div class="mb-3">
<label
@@ -1731,6 +1742,54 @@
</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 ============ -->
<!--
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
// the run on both harnesses (Playwright `pageerror`, and the Firefox
// driver's console-service drain), so nothing is lost by leaving it
// on that path.
// on that path. show() puts the approval's own screen up once the
// background has described it.
approval.show(approvalId);
showView("approve-site");
return;
}
+26 -6
View File
@@ -2,6 +2,8 @@ const {
$,
showView,
showFlash,
showError,
hideError,
goBack,
clearViewStack,
onViewLeave,
@@ -98,6 +100,7 @@ function clear() {
$("add-wallet-password").value = "";
$("add-wallet-password-confirm").value = "";
$("add-wallet-phrase-warning").style.visibility = "hidden";
hideError("add-wallet-password-error");
}
// Each wallet has its own password (its own encryptedSecret), so adding a
@@ -125,15 +128,18 @@ function validatePassword() {
const pw = $("add-wallet-password").value;
const pw2 = $("add-wallet-password-confirm").value;
if (!pw) {
showFlash("Please choose a password.");
showError("add-wallet-password-error", "Please choose a password.");
return null;
}
if (pw.length < 12) {
showFlash("Password must be at least 12 characters.");
showError(
"add-wallet-password-error",
"Password must be at least 12 characters.",
);
return null;
}
if (pw !== pw2) {
showFlash("Passwords do not match.");
showError("add-wallet-password-error", "Passwords do not match.");
return null;
}
return pw;
@@ -178,7 +184,12 @@ async function importMnemonic(ctx) {
encryptedSecret: encrypted,
nextIndex: 1,
addresses: [
{ address: firstAddress, balance: "0.0000", tokenBalances: [] },
{
address: firstAddress,
index: 0,
balance: "0.0000",
tokenBalances: [],
},
],
};
state.wallets.push(wallet);
@@ -199,6 +210,7 @@ async function importMnemonic(ctx) {
if (scan.addresses.length > 1) {
wallet.addresses = scan.addresses.map((a) => ({
address: a.address,
index: a.index,
balance: "0.0000",
tokenBalances: [],
}));
@@ -293,7 +305,12 @@ async function importXprvKey(ctx) {
encryptedSecret: encrypted,
nextIndex: 1,
addresses: [
{ address: firstAddress, balance: "0.0000", tokenBalances: [] },
{
address: firstAddress,
index: 0,
balance: "0.0000",
tokenBalances: [],
},
],
};
state.wallets.push(wallet);
@@ -314,6 +331,7 @@ async function importXprvKey(ctx) {
if (scan.addresses.length > 1) {
wallet.addresses = scan.addresses.map((a) => ({
address: a.address,
index: a.index,
balance: "0.0000",
tokenBalances: [],
}));
@@ -342,8 +360,10 @@ function init(ctx) {
$("add-wallet-phrase-warning").style.visibility = "visible";
});
// Import / confirm
// Import / confirm. Each press starts with no password error on screen:
// validatePassword() puts it back if the password is still wrong.
$("btn-add-wallet-confirm").addEventListener("click", async () => {
hideError("add-wallet-password-error");
if (currentMode === "mnemonic") {
await importMnemonic(ctx);
} else if (currentMode === "privkey") {
+1 -1
View File
@@ -266,7 +266,7 @@ function init(_ctx) {
// This address's private key can be derived from the stored key,
// but export goes through getSignerForAddress, which refuses a key
// that is not a master key. Without this the export screen would
// take a password and then report that refusal as a wrong password.
// take a password and then refuse.
const defect = selectedWalletDefect();
if (defect) {
showFlash(defect.shortMessage);
+115 -30
View File
@@ -12,8 +12,9 @@ const {
formatFee,
tokenLabel,
} = require("./helpers");
const { state, saveState } = require("../../shared/state");
const { state, saveState, currentNetwork } = require("../../shared/state");
const {
networkById,
networkByChainId,
nativeCurrencyByChainId,
} = require("../../shared/networks");
@@ -45,7 +46,10 @@ const {
const { decryptWithPassword } = require("../../shared/vault");
const { getSignerForAddress } = require("../../shared/wallet");
const { walletDefect } = require("../../shared/walletDefects");
const { describeSigningFailure } = require("../../shared/approvalVerify");
const {
describeSigningFailure,
typedDataChainRefusal,
} = require("../../shared/approvalVerify");
const txStatus = require("./txStatus");
const uniswap = require("../../shared/uniswap");
const { notify, runtimeApi, sendMessage } = require("../../shared/browserApi");
@@ -328,9 +332,9 @@ function showTxApproval(details) {
const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null;
const usdStr = formatUsd(ethUsd);
// In the native currency of the network the transaction is for, which the
// Network line names, not the active network's: a site can switch the
// active network after this transaction is prepared and back before it is
// signed.
// Network line names, not the active network's: the active network can
// change, in Settings or when the user approves a site's request, after
// this transaction is prepared and change back before it is signed.
$("approve-tx-value").textContent =
ethValueFormatted +
" " +
@@ -442,7 +446,8 @@ function signedPrimaryType(types) {
// 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
// states has to be the type ethers will sign, or this screen would name one
// message while another is signed.
// message while another is signed. Its domain may name no chain but the active
// network's.
function typedDataRefusal(sp) {
if (sp.method === "personal_sign" || sp.method === "eth_sign") return null;
let data;
@@ -465,7 +470,7 @@ function typedDataRefusal(sp) {
", so it cannot be signed."
);
}
return null;
return typedDataChainRefusal(data.domain, currentNetwork());
}
// The largest amount a Permit2 allowance can hold, a uint160. Permit2 treats
@@ -621,10 +626,38 @@ function permitWarningHtml(types, primaryType, domain, message) {
return html;
}
// What ethers signs of a message value of type `type`: a struct keeps only
// the fields its type declares, in the type's order, and a list is read item
// by item. ethers drops every other key, so each one is added to `notSigned`
// with its place in the message and its value, for the screen to list apart.
function signedValue(types, type, value, path, notSigned) {
const itemType = type.replace(/\[\d*\]$/, "");
if (itemType !== type) {
if (!Array.isArray(value)) return value;
return value.map((item, i) =>
signedValue(types, itemType, item, `${path}[${i}]`, notSigned),
);
}
const fields = types[type];
if (!fields || !value || typeof value !== "object") return value;
const at = (name) => (path ? `${path}.${name}` : name);
for (const [key, val] of Object.entries(value)) {
if (!fields.some((f) => f.name === key)) notSigned.push([at(key), val]);
}
return Object.fromEntries(
fields.map((f) => [
f.name,
signedValue(types, f.type, value[f.name], at(f.name), notSigned),
]),
);
}
// The typed data as the screen shows it. The primary type shown is the one
// ethers signs, never the page's word for it; typedDataRefusal() keeps the two
// from differing on anything that can be signed. Only typed data that cannot
// be read at all is shown as raw text, and typedDataRefusal() refuses it.
// from differing on anything that can be signed. The Message section shows
// only what is signed, and any other key the page sent is listed after it as
// not part of the signature. Only typed data that cannot be read at all is
// shown as raw text, and typedDataRefusal() refuses it.
function formatTypedDataHtml(jsonStr) {
let data;
let primaryType;
@@ -658,12 +691,30 @@ function formatTypedDataHtml(jsonStr) {
html += `<div class="mb-2"><div class="text-muted">Primary type</div>`;
html += `<div class="font-bold">${escapeHtml(primaryType)}</div></div>`;
const lineHtml = (key, val) =>
`<div><span class="text-muted">${escapeHtml(key)}:</span> <span class="break-all">${escapeHtml(display(val))}</span></div>`;
if (data.message) {
const notSigned = [];
const message = signedValue(
data.types,
primaryType,
data.message,
"",
notSigned,
);
html += `<div class="mb-2"><div class="text-muted">Message</div>`;
for (const [key, val] of Object.entries(data.message)) {
html += `<div><span class="text-muted">${escapeHtml(key)}:</span> <span class="break-all">${escapeHtml(display(val))}</span></div>`;
for (const [key, val] of Object.entries(message)) {
html += lineHtml(key, val);
}
html += `</div>`;
if (notSigned.length > 0) {
html += `<div class="mb-2"><div class="text-muted">Not part of the signature</div>`;
for (const [key, val] of notSigned) {
html += lineHtml(key, val);
}
html += `</div>`;
}
}
return html;
@@ -720,7 +771,7 @@ function showSignApproval(details) {
"This message is plain text, not hex, so it cannot be signed.";
}
// Display danger warning for eth_sign (raw hash signing)
// The warning the background attaches to an eth_sign request.
const warningEl = $("approve-sign-danger-warning");
if (warningEl) {
if (sp.dangerWarning) {
@@ -752,9 +803,29 @@ 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
// 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.
//
// 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) {
approvalId = id;
approvalPort = runtimeApi().connect({ name: "approval:" + id });
@@ -778,6 +849,10 @@ async function show(id) {
showSignApproval(details);
return;
}
if (details.type === "network") {
showNetworkApproval(details);
return;
}
// Site connection approval
showPhishingWarning(
"approve-site-phishing-warning",
@@ -787,6 +862,7 @@ async function show(id) {
$("approve-address").innerHTML = approvalAddressHtml(state.activeAddress);
attachCopyHandlers("view-approve-site");
$("approve-remember").checked = state.rememberSiteChoice;
showView("approve-site");
}
let approvalId = null;
@@ -837,15 +913,15 @@ function gateOnWalletDefect(errorId, buttonId, address) {
return true;
}
// Locate the wallet and the address index owning an address. Returns null when
// no wallet holds it. Approvals look up the address they were raised for, not
// whichever address is active now: the approval named one account, and signing
// with another is what verification refuses.
// Locate the wallet and the stored address record owning an address. Returns
// null when no wallet holds it. Approvals look up the address they were raised
// for, not whichever address is active now: the approval named one account,
// and signing with another is what verification refuses.
function findWalletFor(address) {
for (const wallet of state.wallets) {
for (let i = 0; i < wallet.addresses.length; i++) {
if (wallet.addresses[i].address === address) {
return { wallet, addrIndex: i };
return { wallet, addr: wallet.addresses[i] };
}
}
}
@@ -866,20 +942,21 @@ function clearSignPassword() {
hideError("approve-sign-error");
}
// Answer a site-connection approval and close. The decision goes out on the
// approval port — see approvalPort above for why — and carries no approval id,
// because the port name already names the approval the background will settle.
// The post is guarded because a throw must not cost the close: posting on a
// port whose background worker has been torn down throws, and the approval it
// would have settled died with that worker, so the only thing left to do is
// what the user asked for — go away.
function decideSite(approved) {
// Answer a site-connection or network-switch approval and close. The decision
// goes out on the approval port — see approvalPort above for why — and carries
// no approval id, because the port name already names the approval the
// background will settle. `remember` means something only for a site
// connection. The post is guarded because a throw must not cost the close:
// posting on a port whose background worker has been torn down throws, and the
// approval it would have settled died with that worker, so the only thing left
// to do is what the user asked for — go away.
function decide(approved, remember) {
if (approvalPort) {
try {
approvalPort.postMessage({
type: "AUTISTMASK_APPROVAL_DECISION",
approved,
remember: $("approve-remember").checked,
remember,
});
} catch {
// Nothing to report it to; the window closes either way.
@@ -898,11 +975,19 @@ function init(_ctx) {
});
$("btn-approve").addEventListener("click", () => {
decideSite(true);
decide(true, $("approve-remember").checked);
});
$("btn-reject").addEventListener("click", () => {
decideSite(false);
decide(false, $("approve-remember").checked);
});
$("btn-approve-network").addEventListener("click", () => {
decide(true, false);
});
$("btn-reject-network").addEventListener("click", () => {
decide(false, false);
});
$("btn-approve-tx").addEventListener("click", async () => {
@@ -963,7 +1048,7 @@ function init(_ctx) {
try {
const signer = getSignerForAddress(
active.wallet,
active.addrIndex,
active.addr,
decryptedSecret,
);
// Sign the approved transaction exactly as it was displayed. The
@@ -1091,7 +1176,7 @@ function init(_ctx) {
try {
const signer = getSignerForAddress(
active.wallet,
active.addrIndex,
active.addr,
decryptedSecret,
);
const sp = pendingSignParams;
+35 -10
View File
@@ -101,6 +101,10 @@ function show(txInfo) {
feeStatus = FEE_PENDING;
feeWei = null;
maxSendFees = null;
// The network this screen is drawn for, and the only one it sends on:
// Settings can switch the active network while the screen is open. The
// wait, success and error screens label the amount by it too.
txInfo.chainId = currentNetwork().chainId;
const isErc20 = txInfo.token !== "ETH";
// The raw symbol is the price-table key; the capped one is what the
@@ -490,7 +494,12 @@ async function populateVerifyAndSend(connectedSigner, tx, fees = null) {
);
request = await contract.transfer.populateTransaction(tx.to, amount);
}
const populated = await connectedSigner.populateTransaction(request);
// The chain id of the network the screen was drawn for: ethers refuses to
// populate it for a provider on any other network.
const populated = await connectedSigner.populateTransaction({
...request,
chainId: tx.chainId,
});
assertWithinCeilings(populated);
return connectedSigner.sendTransaction(populated);
}
@@ -547,6 +556,30 @@ function init(_ctx) {
return;
}
// Back from Settings only unhides this screen, still drawn for the
// network that was active when it was shown.
if (currentNetwork().chainId !== pendingTx.chainId) {
showSendError(
"The active network changed after this transaction was" +
" reviewed, so it was not sent. Please go back and review" +
" it again.",
);
return;
}
// Sign as the From address this screen shows. The selection is saved
// apart from this screen, so the popup can reopen onto it with another
// address selected.
const addr = wallet.addresses.find((a) => a.address === pendingTx.from);
if (!addr) {
showError(
"confirm-tx-password-error",
"The selected wallet does not hold the From address above, " +
"so nothing was sent. Please go back and send again.",
);
return;
}
const password = $("confirm-tx-password").value;
if (!password) {
showError(
@@ -575,17 +608,9 @@ function init(_ctx) {
$("btn-confirm-send").disabled = true;
$("btn-confirm-send").classList.add("text-muted");
// The network it is sent on. The wait, success and error screens
// label its amount by this, not by the network active when they draw.
pendingTx.chainId = currentNetwork().chainId;
let tx;
try {
const signer = getSignerForAddress(
wallet,
state.selectedAddress,
decryptedSecret,
);
const signer = getSignerForAddress(wallet, addr, decryptedSecret);
const provider = getProvider(state.rpcUrl, state.networkId);
const connectedSigner = signer.connect(provider);
+16 -11
View File
@@ -2,6 +2,8 @@ const {
$,
showView,
showFlash,
showError,
hideError,
goBack,
clearViewStack,
onViewLeave,
@@ -55,8 +57,7 @@ function confirmKey(name) {
function clear() {
deleteWalletIndex = null;
$("delete-wallet-password").value = "";
$("delete-wallet-flash").textContent = "";
$("delete-wallet-flash").style.visibility = "hidden";
hideError("delete-wallet-password-error");
}
// The lost-password screen holds no secret — a wallet name is not one —
@@ -232,19 +233,22 @@ function init(_ctx) {
$("btn-delete-wallet-confirm").addEventListener("click", async () => {
const pw = $("delete-wallet-password").value;
if (!pw) {
$("delete-wallet-flash").textContent =
"Please enter your password.";
$("delete-wallet-flash").style.visibility = "visible";
showError(
"delete-wallet-password-error",
"Please enter your password.",
);
return;
}
if (deleteWalletIndex === null) {
$("delete-wallet-flash").textContent =
"No wallet selected for deletion.";
$("delete-wallet-flash").style.visibility = "visible";
showError(
"delete-wallet-password-error",
"No wallet selected for deletion.",
);
return;
}
hideError("delete-wallet-password-error");
const btn = $("btn-delete-wallet-confirm");
btn.disabled = true;
btn.classList.add("text-muted");
@@ -256,9 +260,10 @@ function init(_ctx) {
try {
await decryptWithPassword(wallet.encryptedSecret, pw);
} catch {
$("delete-wallet-flash").textContent =
"That password is incorrect. Please try again.";
$("delete-wallet-flash").style.visibility = "visible";
showError(
"delete-wallet-password-error",
"That password is incorrect. Please try again.",
);
btn.disabled = false;
btn.classList.remove("text-muted");
return;
+25 -13
View File
@@ -20,6 +20,8 @@ const {
$,
showView,
showFlash,
showError,
hideError,
flashCopyFeedback,
goBack,
onViewLeave,
@@ -56,11 +58,6 @@ 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
// when nothing was ever revealed, and safe to call twice.
function clear() {
@@ -71,8 +68,7 @@ function clear() {
$("export-privkey-password").value = "";
$("export-privkey-result").classList.add("hidden");
$("export-privkey-password-section").classList.remove("hidden");
$("export-privkey-flash").textContent = "";
$("export-privkey-flash").style.visibility = "hidden";
hideError("export-privkey-password-error");
}
function show(walletIdx, addrIdx) {
@@ -112,15 +108,19 @@ function show(walletIdx, addrIdx) {
async function reveal() {
const password = $("export-privkey-password").value;
if (!password) {
fail("Please enter your password.");
showError(
"export-privkey-password-error",
"Please enter your password.",
);
return;
}
if (walletIndex === null) {
fail("No address is selected.");
showError("export-privkey-password-error", "No address is selected.");
return;
}
const wallet = state.wallets[walletIndex];
hideError("export-privkey-password-error");
const btn = $("btn-export-privkey-confirm");
btn.disabled = true;
btn.classList.add("text-muted");
@@ -135,16 +135,28 @@ async function reveal() {
// decrypt ran, the wipe has already happened, so the key is not
// even derived, let alone written.
if (!isCurrentReveal(generation)) return;
const signer = getSignerForAddress(wallet, addressIndex, secret);
let signer;
try {
signer = getSignerForAddress(
wallet,
wallet.addresses[addressIndex],
secret,
);
} catch (e) {
// The password was right; it is the key that was refused.
showError("export-privkey-password-error", e.message);
return;
}
$("export-privkey-password").value = "";
$("export-privkey-password-section").classList.add("hidden");
$("export-privkey-value").textContent = signer.privateKey;
$("export-privkey-result").classList.remove("hidden");
$("export-privkey-flash").textContent = "";
$("export-privkey-flash").style.visibility = "hidden";
} catch {
if (!isCurrentReveal(generation)) return;
fail("That password is incorrect. Please try again.");
showError(
"export-privkey-password-error",
"That password is incorrect. Please try again.",
);
} finally {
btn.disabled = false;
btn.classList.remove("text-muted");
+1
View File
@@ -51,6 +51,7 @@ const VIEWS = [
"approve-site",
"approve-tx",
"approve-sign",
"approve-network",
"export-privkey",
"show-phrase",
// Shown by src/popup/views/stateRecovery.js when the stored profile
+1
View File
@@ -354,6 +354,7 @@ function render(ctx) {
);
wallet.addresses.push({
address: newAddr,
index: wallet.nextIndex,
balance: "0.0000",
tokenBalances: [],
});
+4
View File
@@ -316,7 +316,11 @@ function init(ctx) {
const networkSelect = $("settings-network");
networkSelect.addEventListener("change", async () => {
const newId = networkSelect.value;
if (newId === state.networkId) return;
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-blockscout").value = state.blockscoutUrl;
showFlash("Switched to " + net.name + ".");
+14 -13
View File
@@ -21,6 +21,8 @@ const {
$,
showView,
showFlash,
showError,
hideError,
flashCopyFeedback,
goBack,
onViewLeave,
@@ -52,11 +54,6 @@ 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
// call when nothing was ever revealed, and safe to call twice.
function clear() {
@@ -66,8 +63,7 @@ function clear() {
$("show-phrase-password").value = "";
$("show-phrase-result").classList.add("hidden");
$("show-phrase-password-section").classList.remove("hidden");
$("show-phrase-flash").textContent = "";
$("show-phrase-flash").style.visibility = "hidden";
hideError("show-phrase-password-error");
}
function show(walletIdx) {
@@ -90,19 +86,23 @@ function show(walletIdx) {
async function reveal() {
const password = $("show-phrase-password").value;
if (!password) {
fail("Please enter your password.");
showError("show-phrase-password-error", "Please enter your password.");
return;
}
if (walletIndex === null) {
fail("No wallet is selected.");
showError("show-phrase-password-error", "No wallet is selected.");
return;
}
const wallet = state.wallets[walletIndex];
if (!walletHasRecoveryPhrase(wallet)) {
fail("This wallet does not have a recovery phrase.");
showError(
"show-phrase-password-error",
"This wallet does not have a recovery phrase.",
);
return;
}
hideError("show-phrase-password-error");
const btn = $("btn-show-phrase-reveal");
btn.disabled = true;
btn.classList.add("text-muted");
@@ -120,13 +120,14 @@ async function reveal() {
$("show-phrase-password-section").classList.add("hidden");
$("show-phrase-value").textContent = phrase;
$("show-phrase-result").classList.remove("hidden");
$("show-phrase-flash").textContent = "";
$("show-phrase-flash").style.visibility = "hidden";
} catch {
if (!isCurrentReveal(generation)) return;
// Deliberately not the caught error: the message is fixed so that
// nothing derived from the ciphertext or the attempt can surface.
fail("That password is incorrect. Please try again.");
showError(
"show-phrase-password-error",
"That password is incorrect. Please try again.",
);
} finally {
btn.disabled = false;
btn.classList.remove("text-muted");
+9 -5
View File
@@ -84,8 +84,9 @@ function show(tx) {
contractAddress: tx.contractAddress || null,
// The network the history entry was read from. The type line and
// the fee are in its native currency, not the active network's:
// a site can switch the active network before a later popup
// shows this screen again.
// the active network can change, in Settings or when the user
// approves a site's request, before a later popup shows this
// screen again.
chainId: tx.chainId,
},
};
@@ -182,7 +183,7 @@ function render() {
if (el) el.classList.add("hidden");
}
loadFullTxDetails(tx.hash, tx.to, tx.chainId);
loadFullTxDetails(tx.hash, tx.chainId);
const isoStr = isoDate(tx.timestamp);
$("tx-detail-time").innerHTML =
@@ -290,7 +291,7 @@ function populateOnChainDetails(txData, chainId) {
}
}
async function loadFullTxDetails(txHash, toAddress, chainId) {
async function loadFullTxDetails(txHash, chainId) {
const section = $("tx-detail-calldata-section");
const actionEl = $("tx-detail-calldata-action");
const detailsEl = $("tx-detail-calldata-details");
@@ -312,7 +313,10 @@ async function loadFullTxDetails(txHash, toAddress, chainId) {
const inputData = txData.raw_input || txData.input || null;
if (!inputData || inputData === "0x") return;
const decoded = decodeCalldata(inputData, toAddress || "");
// Decoded against the address the transaction was sent to, as the
// explorer reports it, not the history entry's `to`: for a token
// transfer that is the transfer's recipient, not the token contract.
const decoded = decodeCalldata(inputData, txData.to?.hash || "");
if (decoded) {
// Render decoded calldata matching approval view style
actionEl.textContent = decoded.name;
+3 -2
View File
@@ -89,8 +89,9 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
// 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
// on, not the active network's: a site can switch the active network
// while this screen is open or before a later popup resumes it.
// on, not the active network's: the active network can change, in
// Settings or when the user approves a site's request, while this screen
// is open or before a later popup resumes it.
const symbol =
txInfo.token === "ETH"
? nativeCurrencyByChainId(txInfo.chainId)
+53 -15
View File
@@ -1,13 +1,14 @@
// Preparation of the transaction an approval screen displays.
//
// A dApp's eth_sendTransaction normally fixes only `to`, `value` and `data`.
// The nonce, the gas limit and the fees have to be filled in from the network
// before anything can be signed, and whoever fills them in decides what the
// user is shown. That work used to happen in the popup, after the user had
// already approved: the numbers on the approval screen came from the popup and
// were compared against nothing, so a compromised popup could display one fee
// and sign another, and the ceilings in approvalVerify.js were all that stood
// between the user and a fee that hands the validator the balance.
// A dApp's eth_sendTransaction normally fixes `to`, `value` and `data`, and
// sometimes the gas limit. The nonce, the fees, and the gas limit when the
// site gives none have to be filled in from the network before anything can
// be signed, and whoever fills them in decides what the user is shown. That
// work used to happen in the popup, after the user had already approved: the
// numbers on the approval screen came from the popup and were compared against
// nothing, so a compromised popup could display one fee and sign another, and
// the ceilings in approvalVerify.js were all that stood between the user and a
// fee that hands the validator the balance.
//
// So it happens here instead, in the background, before the approval window is
// opened. The background populates the transaction, shows that object, and
@@ -31,6 +32,7 @@ const {
VoidSigner,
accessListify,
getAddress,
getBigInt,
getBytes,
hexlify,
toQuantity,
@@ -47,10 +49,11 @@ const {
// minutes long, which is not a wait anyone will sit through.
const POPULATE_TIMEOUT_MS = 20000;
// The request fields taken from the page. Anything else is dropped rather than
// passed to ethers: the object is page-controlled, and a future ethers that
// learns to carry a new transaction field must not start picking one up out of
// it without this module knowing.
// The request fields taken from the page, plus `gas`, which requestFrom()
// takes as the gas limit. Anything else is dropped rather than passed to
// ethers: the object is page-controlled, and a future ethers that learns to
// carry a new transaction field must not start picking one up out of it
// without this module knowing.
//
// 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
@@ -84,6 +87,16 @@ function present(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
// sentences even when the tail of one came from ethers or from the node.
function sentence(text) {
@@ -111,6 +124,20 @@ function requestFrom(txParams, from) {
for (const key of REQUEST_FIELDS) {
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 (
present(request.type) &&
!ALLOWED_TX_TYPES.includes(Number(request.type))
@@ -174,6 +201,7 @@ async function prepareApprovalTx(provider, from, txParams) {
throw fail("There is no active address to send this transaction from.");
}
const request = requestFrom(txParams || {}, from);
const signer = new VoidSigner(getAddress(from), provider);
let populated;
try {
@@ -181,10 +209,20 @@ async function prepareApprovalTx(provider, from, txParams) {
// nonce, gas, fee and chain id are populated exactly as they were when
// the popup did this. VoidSigner cannot sign, which is the point: the
// background prepares, the popup signs.
//
// ethers asks the node for a gas estimate only when the request has
// no gas limit, and that estimate is the only check that the
// transaction does not revert. So a request carrying the site's gas
// limit is estimated first, with that limit: one that reverts, or
// needs more gas than the site gave it, fails here as well. The
// site's gas limit is still the one populated.
populated = await withTimeout(
new VoidSigner(getAddress(from), provider).populateTransaction(
request,
),
(async () => {
if (present(request.gasLimit)) {
await signer.estimateGas(request);
}
return signer.populateTransaction(request);
})(),
POPULATE_TIMEOUT_MS,
"The transaction could not be prepared: the network did not answer in time.",
);
+29
View File
@@ -53,6 +53,7 @@ const {
accessListify,
formatEther,
getAddress,
getBigInt,
getBytes,
toQuantity,
verifyMessage,
@@ -635,6 +636,33 @@ function verifySignature(signParams, signature, expectedFrom) {
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
// whether the approval survives the failure.
const TX_STAGE_SIGN = "sign";
@@ -796,6 +824,7 @@ function describeSigningFailure(response, fallbackMessage) {
module.exports = {
verifySignedTx,
verifySignature,
typedDataChainRefusal,
assertNoForbiddenFields,
assertNothingUnchecked,
assertCanonicalBytes,
+4 -1
View File
@@ -110,11 +110,14 @@ function hashListed(hash) {
* Generate hostname variants for subdomain matching.
* "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
* @returns {string[]}
*/
function hostnameVariants(hostname) {
const h = hostname.toLowerCase();
const h = hostname.toLowerCase().replace(/\.$/, "");
const variants = [h];
const parts = h.split(".");
// Parent domains: a.b.c.d -> b.c.d, c.d
+2 -2
View File
@@ -576,7 +576,7 @@ const TOKENS = [
symbol: "FLOKI",
decimals: 9,
name: "FLOKI",
url: "",
url: "https://floki.com",
},
{
address: "0x7420B4b9a0110cdC71fB720908340C03F9Bc03EC",
@@ -1290,7 +1290,7 @@ const TOKENS = [
symbol: "TURBO",
decimals: 18,
name: "Turbo",
url: "",
url: "https://www.turbo-eth.com",
},
{
address: "0xA27EC0006e59f245217Ff08CD52A7E8b169E62D2",
+32 -6
View File
@@ -110,16 +110,33 @@ function addressFromPrivateKey(key) {
return w.address;
}
function getSignerForAddress(walletData, addrIndex, decryptedSecret) {
// The signer for `addr`, one of the wallet's stored address records. An HD or
// xprv address is derived at its own `index`, never at its position in the
// wallet's list: the two differ once an import scan passes over an unused
// index, or once an address that is not the last is removed. An HD or xprv
// record without a valid `index` is refused before anything is derived.
// Whatever the wallet type, the key is refused unless it is the key of
// `addr.address`.
function getSignerForAddress(walletData, addr, decryptedSecret) {
if (
(walletData.type === "hd" || walletData.type === "xprv") &&
!(Number.isInteger(addr.index) && addr.index >= 0)
) {
throw new Error(
"No derivation index is stored for " +
addr.address +
", so its key cannot be derived.",
);
}
let signer;
if (walletData.type === "hd") {
const node = HDNodeWallet.fromPhrase(
decryptedSecret,
"",
BIP44_ETH_PATH,
);
return node.deriveChild(addrIndex);
}
if (walletData.type === "xprv") {
signer = node.deriveChild(addr.index);
} else if (walletData.type === "xprv") {
// Checked here rather than through masterXprvOrThrow so the message
// fits the situation: nobody is importing anything at signing time,
// and this wallet is already in storage. src/shared/walletDefects.js
@@ -137,9 +154,18 @@ function getSignerForAddress(walletData, addrIndex, decryptedSecret) {
"is not a master key, so it cannot sign.",
);
}
return node.derivePath(BIP44_ETH_PATH).deriveChild(addrIndex);
signer = node.derivePath(BIP44_ETH_PATH).deriveChild(addr.index);
} else {
signer = new Wallet(decryptedSecret);
}
return new Wallet(decryptedSecret);
if (signer.address.toLowerCase() !== addr.address.toLowerCase()) {
throw new Error(
"The key this wallet holds for " +
addr.address +
" belongs to a different address, so it was not used.",
);
}
return signer;
}
function isValidMnemonic(mnemonic) {
+7 -5
View File
@@ -36,11 +36,13 @@ const DEFECTS = {
"derivation path beneath that key instead of from a master " +
"key, so the addresses listed here are not the ones that key " +
"produces under the standard path.",
"Signing and sending are disabled for this wallet. The addresses " +
"do descend from the extended private key you imported, so " +
"anything they hold is still reachable by software that " +
"repeats the same non-standard derivation. Check them in a " +
"block explorer before deciding what to do.",
"Signing, sending and private key export are disabled for this " +
"wallet. Anything these addresses hold can be reached outside " +
"this wallet 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. Check the " +
"addresses in a block explorer before deciding what to do.",
"To see the addresses this key produces under the standard path, " +
"import the master extended private key, or the recovery " +
"phrase it came from, as a new wallet. Nothing here has been " +
+490
View File
@@ -0,0 +1,490 @@
// Sending, a site's transaction and signature requests and private key export
// each use the key of the address they name: the key at the index that address
// was derived at, not the one at its position in the wallet's list. The two
// differ after an import whose scan passed over an unused index, and after an
// address that is not the last is removed
// (https://git.eeqj.de/sneak/AutistMask/issues/510). A send is signed by the
// From address its confirmation screen shows, whichever address is selected.
//
// Driven through the real import, "+" button, address removal, confirmation
// screen, transaction and signature approval screens and export screen, with
// the node, the vault and the DOM stubbed.
"use strict";
// The addresses the stub node reports a balance for, and the signed
// transactions it was handed.
const mockNode = { funded: new Set(), broadcast: [] };
jest.mock("ethers", () => {
const actual = jest.requireActual("ethers");
class StubProvider {
async getBalance(address) {
return mockNode.funded.has(address) ? 1n : 0n;
}
async getTransactionCount() {
return 0;
}
async estimateGas() {
return 21000n;
}
async getFeeData() {
return {
maxFeePerGas: 20000000000n,
maxPriorityFeePerGas: 1000000000n,
gasPrice: 10000000000n,
};
}
async getNetwork() {
return { chainId: 1n };
}
async getCode() {
return "0x";
}
async lookupAddress() {
return null;
}
async broadcastTransaction(signed) {
mockNode.broadcast.push(signed);
return { hash: "0x" + "ab".repeat(32) };
}
}
return { ...actual, JsonRpcProvider: StubProvider };
});
// No password is hashed: what the wallet stores is the phrase itself.
jest.mock("../src/shared/vault", () => ({
encryptWithPassword: async (secret) => secret,
decryptWithPassword: async (secret) => secret,
}));
// The wait screen polls for a receipt; these tests stop at the broadcast.
jest.mock("../src/popup/views/txStatus", () => ({
showWait: jest.fn(),
showError: jest.fn(),
}));
jest.mock("ethereum-blockies-base64", () => () => "data:image/png;base64,x");
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
const {
HDNodeWallet,
Mnemonic,
Transaction,
getBytes,
verifyMessage,
} = require("ethers");
// Publicly known development phrase; never fund it.
const PHRASE = "test test test test test test test test test test test junk";
// PHRASE's master extended private key, as the xprv import takes it.
const XPRV = HDNodeWallet.fromSeed(
Mnemonic.fromPhrase(PHRASE).computeSeed(),
).extendedKey;
const PASSWORD = "correct horse battery staple";
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
// "Hello", as the hex a page sends.
const MESSAGE = "0x48656c6c6f";
// The address and key at derivation index `index` of PHRASE, worked out with
// ethers directly rather than through the wallet's own code.
function derived(index) {
return HDNodeWallet.fromPhrase(PHRASE, "", "m/44'/60'/0'/0/" + index);
}
// ------------------------------------------------------------ DOM stub
function makeElement(id) {
const classes = new Set();
const el = {
id,
textContent: "",
title: "",
value: "",
innerHTML: "",
disabled: false,
style: {},
dataset: {},
listeners: {},
classList: {
add: (...names) => names.forEach((n) => classes.add(n)),
remove: (...names) => names.forEach((n) => classes.delete(n)),
contains: (n) => classes.has(n),
toggle: (n, force) => {
const on = force === undefined ? !classes.has(n) : force;
if (on) classes.add(n);
else classes.delete(n);
return on;
},
},
addEventListener: (name, fn) => {
el.listeners[name] = el.listeners[name] || [];
el.listeners[name].push(fn);
},
querySelectorAll: () => [],
appendChild: () => {},
};
// Views reach for .parentElement to hide whole sections.
Object.defineProperty(el, "parentElement", {
get: () => node(id + "-parent"),
});
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
// The debug banner is created on demand by helpers.js; absent
// is the state a non-debug, non-testnet popup is in.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
addEventListener: () => {},
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
function click(element) {
const event = { stopPropagation: () => {} };
return Promise.all((element.listeners.click || []).map((fn) => fn(event)));
}
// show() starts the fee estimate without awaiting it; this lets it settle.
async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
// ------------------------------------------------------------ harness
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { state } = require("../src/shared/state");
const { removeAddressFromState } = require("../src/shared/walletDelete");
const addWallet = require("../src/popup/views/addWallet");
const home = require("../src/popup/views/home");
const confirmTx = require("../src/popup/views/confirmTx");
const approval = require("../src/popup/views/approval");
const exportPrivkey = require("../src/popup/views/exportPrivkey");
const txStatus = require("../src/popup/views/txStatus");
// What the background answers when the approval window asks which approval it
// was opened for, and every message the popup sent it.
let approvalDetails;
let sent;
// The "+" button of the wallet list's last render. Each render attaches the
// handler to the buttons it drew, as the browser's fresh list would have.
let plusButton;
beforeEach(() => {
approvalDetails = null;
sent = [];
mockNode.broadcast = [];
globalThis.document = makeDocument();
globalThis.window = { close: () => {} };
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
runtime: {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
sent.push(msg);
if (!reply) return;
reply(
msg.type === "AUTISTMASK_GET_APPROVAL"
? approvalDetails
: null,
);
},
},
};
node("wallet-list").querySelectorAll = (selector) => {
if (selector !== ".btn-add-address") return [];
plusButton = makeElement("btn-add-address");
plusButton.dataset.wallet = "0";
return [plusButton];
};
// The explorer and price lookups these screens start fail offline, and
// say so; that is not under test.
for (const method of ["log", "warn", "error"]) {
jest.spyOn(console, method).mockImplementation(() => {});
}
txStatus.showError.mockClear();
state.wallets = [];
state.rpcUrl = "https://rpc.example.invalid";
state.networkId = "mainnet";
state.viewStack = [];
state.currentView = "main";
addWallet.init({
renderWalletList: () => {},
doRefreshAndRender: () => {},
pageClosed: new AbortController().signal,
});
confirmTx.init({});
approval.init({});
exportPrivkey.init();
});
afterEach(() => {
jest.restoreAllMocks();
});
// Import PHRASE on the add-wallet screen, with the stub node reporting a
// balance at each of `fundedIndices`, and wait for the address scan.
async function importPhrase(fundedIndices) {
mockNode.funded = new Set(fundedIndices.map((i) => derived(i).address));
await click(node("tab-mnemonic"));
node("wallet-mnemonic").value = PHRASE;
node("add-wallet-password").value = PASSWORD;
node("add-wallet-password-confirm").value = PASSWORD;
await click(node("btn-add-wallet-confirm"));
}
// The same, importing XPRV on the add-wallet screen's xprv tab.
async function importXprv(fundedIndices) {
mockNode.funded = new Set(fundedIndices.map((i) => derived(i).address));
await click(node("tab-xprv"));
node("import-xprv-key").value = XPRV;
node("add-wallet-password").value = PASSWORD;
node("add-wallet-password-confirm").value = PASSWORD;
await click(node("btn-add-wallet-confirm"));
}
// Press the wallet's "+" button on Home.
async function addAddress() {
home.render({
doRefreshAndRender: () => {},
pageClosed: new AbortController().signal,
});
await click(plusButton);
}
// The key the export screen reveals for the address at `position`.
async function exportedKey(position) {
exportPrivkey.show(0, position);
node("export-privkey-password").value = PASSWORD;
await click(node("btn-export-privkey-confirm"));
return node("export-privkey-value").textContent;
}
// Confirm a send whose From line shows `from`, with the address at position
// `selected` selected; resolves to the signed transaction the node was handed,
// or undefined when none was.
async function confirmSend(from, selected) {
state.selectedWallet = 0;
state.selectedAddress = selected;
confirmTx.show({
from,
to: RECIPIENT,
amount: "0.5",
token: "ETH",
balance: "1.0",
});
await settle();
node("confirm-tx-password").value = PASSWORD;
await click(node("btn-confirm-send"));
return mockNode.broadcast.pop();
}
// Send from the address at `position`, with that address selected, as the Send
// button leaves it.
function sendFrom(position) {
return confirmSend(state.wallets[0].addresses[position].address, position);
}
// A site asks the address at `position` to sign MESSAGE; resolves to the
// response the approval window sent the background.
async function signFor(position) {
const address = state.wallets[0].addresses[position].address;
approvalDetails = {
type: "sign",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: address,
signParams: {
method: "personal_sign",
message: MESSAGE,
from: address,
},
};
await approval.show(1);
node("approve-sign-password").value = PASSWORD;
await click(node("btn-approve-sign"));
return sent.find((msg) => msg.type === "AUTISTMASK_SIGN_RESPONSE");
}
// A site asks the address at `position` to send RECIPIENT a transaction of no
// value; resolves to the response the approval window sent the background.
async function transactFor(position) {
const address = state.wallets[0].addresses[position].address;
approvalDetails = {
type: "tx",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: address,
approvedTx: {
type: 2,
from: address,
chainId: "0x1",
nonce: "0x0",
gasLimit: "0x5208",
maxPriorityFeePerGas: "0x3b9aca00",
maxFeePerGas: "0x77359400",
to: RECIPIENT,
value: "0x0",
data: "0x",
accessList: [],
},
};
await approval.show(1);
node("approve-tx-password").value = PASSWORD;
await click(node("btn-approve-tx"));
return sent.find((msg) => msg.type === "AUTISTMASK_TX_RESPONSE");
}
describe.each([
[
"a recovery phrase import whose scan found only the first address",
async () => {
await importPhrase([0]);
},
// The address the import stores before its scan.
{ position: 0, index: 0 },
],
[
"an xprv import whose scan found only the first address",
async () => {
await importXprv([0]);
},
{ position: 0, index: 0 },
],
[
"an import whose scan passed over index 3",
async () => {
await importPhrase([0, 1, 2, 4]);
},
// Address 4 on Home, derived at index 4.
{ position: 3, index: 4 },
],
[
"an xprv import whose scan passed over index 3",
async () => {
await importXprv([0, 1, 2, 4]);
},
{ position: 3, index: 4 },
],
[
"removing the middle one of three addresses",
async () => {
await importPhrase([0]);
await addAddress();
await addAddress();
removeAddressFromState(state, 0, 1);
},
// Address 2 on Home after the removal, derived at index 2.
{ position: 1, index: 2 },
],
])("after %s", (_name, setUp, { position, index }) => {
beforeEach(setUp);
test("the wallet lists the address derived at that index there", () => {
expect(state.wallets[0].addresses[position].address).toBe(
derived(index).address,
);
});
test("export reveals that address's key", async () => {
expect(await exportedKey(position)).toBe(derived(index).privateKey);
});
test("a send is signed by that address", async () => {
const signed = await sendFrom(position);
expect(Transaction.from(signed).from).toBe(derived(index).address);
});
test("a site's signature is made by that address", async () => {
const response = await signFor(position);
expect(verifyMessage(getBytes(MESSAGE), response.signature)).toBe(
derived(index).address,
);
});
test("a site's transaction is signed by that address", async () => {
const response = await transactFor(position);
expect(Transaction.from(response.rawSignedTx).from).toBe(
derived(index).address,
);
});
});
// The selection and the confirmation screen are saved apart, so the popup can
// reopen onto the screen with another address selected than its From line
// shows.
describe("a confirmation screen whose From is not the selected address", () => {
beforeEach(async () => {
await importPhrase([0, 1]);
});
test("the send is signed by the From address shown", async () => {
const signed = await confirmSend(derived(1).address, 0);
expect(Transaction.from(signed).from).toBe(derived(1).address);
});
test("nothing is sent when the selected wallet does not hold it", async () => {
expect(await confirmSend(derived(5).address, 0)).toBeUndefined();
expect(node("confirm-tx-password-error").textContent).toBe(
"The selected wallet does not hold the From address above, " +
"so nothing was sent. Please go back and send again.",
);
});
});
describe("an address whose stored index derives a different address", () => {
function refusal() {
return (
"The key this wallet holds for " +
derived(0).address +
" belongs to a different address, so it was not used."
);
}
beforeEach(async () => {
await importPhrase([0]);
state.wallets[0].addresses[0].index = 1;
});
test("export reveals nothing and says why", async () => {
expect(await exportedKey(0)).toBe("");
expect(node("export-privkey-password-error").textContent).toBe(
refusal(),
);
});
test("nothing is sent", async () => {
expect(await sendFrom(0)).toBeUndefined();
expect(txStatus.showError).toHaveBeenCalledWith(
expect.anything(),
null,
refusal(),
);
});
test("nothing is signed", async () => {
const response = await signFor(0);
expect(response.signature).toBeUndefined();
expect(response.error).toBe(refusal());
});
});
+2
View File
@@ -39,6 +39,8 @@ jest.doMock("../src/popup/views/helpers", () => ({
$: element,
showView: () => {},
showFlash: () => {},
showError: () => {},
hideError: () => {},
goBack: () => {},
clearViewStack: () => {},
onViewLeave: () => {},
+19
View File
@@ -0,0 +1,19 @@
// 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`). This fails when the committed list differs from
// what the script writes from the committed data, and when the data or the
// script's table of websites has an address that no entry in the list matches.
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);
});
+3 -1
View File
@@ -136,7 +136,9 @@ async function openTxApproval(approvedTx) {
xpub: "xpub-wallet-1",
encryptedSecret: "encrypted-secret-1",
nextIndex: 1,
addresses: [{ address: FROM, balance: "0", tokenBalances: [] }],
addresses: [
{ address: FROM, index: 0, balance: "0", tokenBalances: [] },
],
},
];
approval.init({});
+24 -2
View File
@@ -1,5 +1,5 @@
// The connection, transaction and signature prompts name the site by its full
// origin, scheme and port included, not by its bare hostname
// The connection, transaction, signature and network switch prompts name the
// site by its full origin, scheme and port included, not by its bare hostname
// (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
// prompt that reads as that trusted site.
@@ -104,6 +104,7 @@ beforeEach(() => {
test("the connection prompt shows the origin", async () => {
await openApproval({});
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 () => {
@@ -138,3 +139,24 @@ test("the signature prompt shows the origin", async () => {
});
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);
});
+91 -1
View File
@@ -9,7 +9,7 @@
// requesting page or the RPC node can say turns it into an approval that
// should never have been raised.
const { Network, Wallet } = require("ethers");
const { Network, Transaction, Wallet, toQuantity } = require("ethers");
const {
prepareApprovalTx,
serializeApprovedTx,
@@ -138,6 +138,49 @@ describe("prepareApprovalTx", () => {
expect(approved.maxFeePerGas).toBe("0x12a05f200");
});
// `gas` is the JSON-RPC name for the gas limit and the one sites send.
// The node is still asked for its estimate, with the site's gas limit,
// because the estimate is the check that the transaction does not revert.
// It estimates 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 estimateGas = jest.fn(async () => 21000n);
const approved = await prepareApprovalTx(
providerWith({ estimateGas }),
signer.address,
{ ...TX_PARAMS, gas: "0x30d40" },
);
expect(estimateGas).toHaveBeenCalledWith(
expect.objectContaining({ gasLimit: 200000n }),
);
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 () => {
const approved = await prepareApprovalTx(
providerWith(),
@@ -212,6 +255,15 @@ describe("prepareApprovalTx", () => {
).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
// under their own ceiling but multiply to thousands of ETH is refused
// before the approval window opens, so the user is never shown a
@@ -251,6 +303,22 @@ describe("prepareApprovalTx", () => {
expect(thrown.message).toMatch(/^[A-Z].*\.$/);
});
test("fails back a reverting request that sends its own gas limit", async () => {
const reverting = providerWith({
estimateGas: async () => {
throw new Error("execution reverted: ERC20: transfer amount");
},
});
await expect(
prepareApprovalTx(reverting, signer.address, {
...TX_PARAMS,
gas: "0x30d40",
}),
).rejects.toThrow(
/^The transaction could not be prepared: execution reverted/,
);
});
// Without a bound, an unreachable node leaves the page's promise pending
// with nothing on screen to explain it.
test("gives up on a node that never answers", async () => {
@@ -273,6 +341,28 @@ describe("prepareApprovalTx", () => {
jest.useRealTimers();
}
});
// The estimate of a request carrying the site's gas limit is a call of its
// own, made before populating, and is bounded the same way.
test("gives up on a node that never answers the estimate of a request that sends its own gas limit", async () => {
jest.useFakeTimers();
try {
const hanging = providerWith({
estimateGas: () => new Promise(() => {}),
});
const pending = prepareApprovalTx(hanging, signer.address, {
...TX_PARAMS,
gas: "0x30d40",
});
const settled = expect(pending).rejects.toThrow(
/did not answer in time/,
);
await jest.advanceTimersByTimeAsync(POPULATE_TIMEOUT_MS + 1);
await settled;
} finally {
jest.useRealTimers();
}
});
});
describe("serializeApprovedTx", () => {
+10 -3
View File
@@ -58,7 +58,6 @@ const TX_PARAMS = {
to: RECIPIENT,
value: "0x2386f26fc10000",
data: "0xdeadbeef",
gas: "0x5208",
};
// The values populateTransaction() fills in when the dApp fixed none of them.
@@ -1455,7 +1454,11 @@ describe("background population to popup signing to verification", () => {
// What the popup does with it: signs it as given, populating nothing.
async function popupSigns(approvedTx) {
const localSigner = getSignerForAddress(walletData, 0, SIGNER_KEY);
const localSigner = getSignerForAddress(
walletData,
{ address: signer.address },
SIGNER_KEY,
);
return localSigner.signTransaction({ ...approvedTx });
}
@@ -1550,7 +1553,11 @@ describe("background population to popup signing to verification", () => {
// address the user has switched to.
test("the approved from stops the popup signing with another key", async () => {
const approvedTx = await backgroundPrepares(TX_PARAMS);
const otherSigner = getSignerForAddress(walletData, 0, OTHER_KEY);
const otherSigner = getSignerForAddress(
walletData,
{ address: other.address },
OTHER_KEY,
);
await expect(
otherSigner.signTransaction({ ...approvedTx }),
).rejects.toThrow(/from address mismatch/);
+47
View File
@@ -0,0 +1,47 @@
// 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([]);
});
+105 -1
View File
@@ -397,7 +397,13 @@ function loadBackground(options) {
rpcResult = r;
},
);
return { result: () => rpcResult };
return {
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
@@ -1484,6 +1490,55 @@ 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 () => {
const bg = loadBackground();
const pending = bg.requestSign(other.address);
@@ -1551,6 +1606,55 @@ describe("what the approval is verified against", () => {
});
});
// eth_sign names the address first and the data second, the reverse of
// personal_sign, and is answered as the JSON-RPC specification defines it:
// with an EIP-191 personal message signature over the data, the same
// signature personal_sign returns
// (https://git.eeqj.de/sneak/AutistMask/issues/409). The signature below is
// made by the test; that the sign screen makes the same one is tested in
// tests/ethSign.test.js.
describe("eth_sign", () => {
test("is read address first, and returns a personal message signature over the data", async () => {
const bg = loadBackground();
const page = bg.send(
{
type: "AUTISTMASK_RPC",
method: "eth_sign",
params: [signer.address, MESSAGE],
},
{ origin: ORIGIN },
);
await settle();
const id = approvalIdIn(bg.created.map((c) => c.url));
const details = bg.send(
{ type: "AUTISTMASK_GET_APPROVAL", id },
{ url: bg.fromPopup.url },
);
const { signParams } = details.sendResponse.mock.calls[0][0];
expect(signParams).toMatchObject({
method: "eth_sign",
message: MESSAGE,
from: signer.address,
});
expect(signParams.dangerWarning).toBe(
"This site used the older eth_sign method. " +
"The data will be signed as a personal message.",
);
const signature = await signer.signMessage(
Buffer.from(MESSAGE.slice(2), "hex"),
);
bg.send(
{ type: "AUTISTMASK_SIGN_RESPONSE", id, approved: true, signature },
{ url: bg.fromPopup.url },
);
await settle();
expect(page.sendResponse).toHaveBeenCalledWith({ result: signature });
});
});
// The interlock must not cost the retry the approval exists to allow.
describe("the interlock releases a failed attempt", () => {
test("a retryable failure before the broadcast leaves the approval usable", async () => {
+14 -14
View File
@@ -25,6 +25,7 @@
const { Wallet } = require("ethers");
const { networkById } = require("../src/shared/networks");
const { applyChainSwitchFields } = require("../src/shared/chainSwitchFields");
const { makeStorageStub } = require("./support/storageStub");
const SIGNER_KEY =
@@ -240,8 +241,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
// attempt. Whatever endpoint it is then broadcast to has to be that same
// chain's — otherwise the wallet checks a transaction against Sepolia and
// sends it to a mainnet node. A connected site can switch the chain at any
// moment, including this one.
// sends it to a mainnet node. The user can switch the network in Settings
// at any moment, including this one.
test("the artifact is broadcast to the endpoint of the chain it was verified against", async () => {
const bg = loadWorker("sepolia");
@@ -264,9 +265,9 @@ describe("a chain switch under a transaction already committed to a chain", () =
populated(Number(SEPOLIA.networkVersion)),
);
// A connected site switches the chain while the attempt is running,
// and the switch is committed to storage in full before the attempt
// goes any further.
// The user switches the network in Settings while the attempt is
// running: the popup writes the switched record to storage in full
// before the attempt goes any further.
//
// 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
@@ -277,18 +278,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
// done instead — which is the point.
let reads = 0;
let switched = null;
const doSwitch = async () => {
switched = bg.rpc("wallet_switchEthereumChain", [
{ chainId: MAINNET.chainId },
]);
await settle();
let switched = false;
const doSwitch = () => {
const record = bg.persisted();
applyChainSwitchFields(record, MAINNET.id);
global.chrome.storage.write("autistmask", record);
switched = true;
};
bg.setGetHook(async () => {
reads++;
if (reads !== 2) return;
bg.setGetHook(null);
await doSwitch();
doSwitch();
});
const attempt = bg.send(
@@ -303,8 +304,7 @@ describe("a chain switch under a transaction already committed to a chain", () =
await settle();
bg.setGetHook(null);
if (!switched) await doSwitch();
expect(switched.result()).toEqual({ result: null });
if (!switched) doSwitch();
expect(bg.persisted().networkId).toBe("mainnet");
await settle();
+328 -42
View File
@@ -1,16 +1,24 @@
// Who may move the active chain.
// Who may move the active chain, and when.
//
// 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
// to could clear the [TESTNET] banner under someone who believed they were
// on Sepolia (https://git.eeqj.de/sneak/AutistMask/issues/308). The refusal
// is asserted as a refusal to ACT — the state unmoved and no chainChanged
// broadcast — because an error code alone would not distinguish a gate from
// a switch that happened and then reported a failure.
// on Sepolia (https://git.eeqj.de/sneak/AutistMask/issues/308). Gated on the
// connection, a connected site could still move the wallet between mainnet and
// Sepolia without asking. Only the user switches the network: a connected
// site's request opens a prompt, and nothing changes unless the user approves
// it there (https://git.eeqj.de/sneak/AutistMask/issues/408).
//
// The endpoint half of that issue 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.
// Every refusal is asserted as a refusal to ACT — the stored record unmoved
// and no chainChanged broadcast — because an error code alone would not
// distinguish a refusal from a switch that happened and then reported a
// 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 { makeStorageStub } = require("./support/storageStub");
@@ -21,18 +29,23 @@ const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const CONNECTED_ORIGIN = "https://dapp.example";
const STRANGER_ORIGIN = "https://stranger.example";
const EXT_URL = "chrome-extension://autistmask/";
const MAINNET = networkById("mainnet");
const SEPOLIA = networkById("sepolia");
// 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";
// A balance a switch would clear, so a switch that happens is visible here too.
function walletFixture() {
return [
{
name: "Wallet 1",
type: "hd",
addresses: [{ address: ADDRESS, balance: "0", tokenBalances: [] }],
addresses: [
{ address: ADDRESS, balance: "1.5", tokenBalances: [] },
],
},
];
}
@@ -47,10 +60,6 @@ afterEach(() => {
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
// chain-switch and persistence modules behind it, and return the handles to
// drive it.
@@ -91,30 +100,46 @@ function loadBackground() {
const storage = makeStorageStub({ autistmask: persisted });
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
// is what tells a page the wallet moved, so an ungated switch is visible
// here as well as in the state.
// is what tells a page the wallet moved, so a switch is visible here as
// well as in the state.
const toTabs = [];
global.chrome = {
storage,
runtime: {
getURL: (path) => "chrome-extension://autistmask/" + path,
getURL: (path) => EXT_URL + path,
onMessage: {
addListener: (fn) => {
messageListener = fn;
},
},
onConnect: { addListener: () => {} },
onConnect: {
addListener: (fn) => {
connectListener = fn;
},
},
lastError: null,
},
windows: {
getLastFocused: (cb) => cb(null),
create: (options, cb) => cb({ id: 1 }),
create: (options, cb) => {
opened.push(options.url);
cb({ id: opened.length });
},
remove: (id, cb) => {
if (cb) cb();
},
onRemoved: { addListener: () => {} },
onRemoved: {
addListener: (fn) => {
windowRemovedListener = fn;
},
},
},
tabs: {
query: (queryInfo, cb) => cb([{ id: 1 }]),
@@ -128,6 +153,8 @@ function loadBackground() {
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) {
let result = null;
messageListener(
@@ -142,55 +169,309 @@ function loadBackground() {
},
);
await settle();
return result;
return { result: () => 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 {
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"),
chainChangedEvents: () =>
toTabs.filter((m) => m.eventName === "chainChanged"),
};
}
const USER_REJECTED = { code: 4001, message: "User rejected the request." };
describe("wallet_switchEthereumChain is gated on the connection", () => {
test("an origin the wallet was never connected to is refused with 4100", async () => {
const bg = loadBackground();
const before = bg.walletState();
const result = await bg.switchChain(SEPOLIA.chainId, STRANGER_ORIGIN);
const request = await bg.switchChain(SEPOLIA.chainId, STRANGER_ORIGIN);
expect(result.error).toEqual({ code: 4100, message: "Unauthorized" });
expect(result.result).toBeUndefined();
expect(request.result().error).toEqual({
code: 4100,
message: "Unauthorized",
});
expect(request.result().result).toBeUndefined();
// 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
// no page was told the chain moved.
expect(bg.walletState().networkId).toBe("mainnet");
expect(bg.walletState().rpcUrl).toBe(CUSTOM_RPC);
// no page was told the chain moved. Nor was the user asked.
expect(bg.walletState()).toEqual(before);
expect(bg.chainChangedEvents()).toEqual([]);
expect(bg.opened).toEqual([]);
});
test("an unconnected origin is refused even for the chain already active", async () => {
const bg = loadBackground();
const result = await bg.switchChain(MAINNET.chainId, STRANGER_ORIGIN);
const request = await bg.switchChain(MAINNET.chainId, STRANGER_ORIGIN);
expect(result.error).toEqual({ code: 4100, message: "Unauthorized" });
expect(request.result().error).toEqual({
code: 4100,
message: "Unauthorized",
});
});
test("an unconnected origin is refused before the unsupported-chain answer", async () => {
const bg = loadBackground();
const result = await bg.switchChain("0x89", STRANGER_ORIGIN);
const request = await bg.switchChain("0x89", STRANGER_ORIGIN);
expect(result.error.code).toBe(4100);
expect(request.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("a connected origin switches the chain", async () => {
test("approving the prompt switches the network", async () => {
const bg = loadBackground();
const result = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
const request = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
await bg.answerPrompt(true);
expect(request.result()).toEqual({ result: null });
const after = bg.walletState();
expect(after.networkId).toBe("sepolia");
expect(after.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
expect(after.wallets[0].addresses[0].balance).toBe("0");
expect(bg.chainChangedEvents()).toEqual([
{
type: "AUTISTMASK_EVENT",
eventName: "chainChanged",
data: SEPOLIA.chainId,
},
]);
});
test("rejecting the prompt changes nothing and answers 4001", 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 first = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
const second = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
expect(second.result().error.code).toBe(-32002);
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");
});
test("an approved switch keeps the user's endpoint", async () => {
const bg = loadBackground();
await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
await bg.answerPrompt(true);
expect(bg.walletState().rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
await bg.switchChain(MAINNET.chainId, CONNECTED_ORIGIN);
await bg.answerPrompt(true);
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(result).toEqual({ result: null });
expect(bg.walletState().networkId).toBe("sepolia");
expect(bg.chainChangedEvents()).toEqual([
{
@@ -201,22 +482,27 @@ describe("wallet_switchEthereumChain is gated on the connection", () => {
]);
});
test("a connected origin asking for an unsupported chain still gets 4902", async () => {
test("choosing the network already active changes nothing and sends nothing", async () => {
const bg = loadBackground();
const select = await openSettings(bg);
const before = bg.walletState();
const result = await bg.switchChain("0x89", CONNECTED_ORIGIN);
await choose(select, "mainnet");
expect(result.error.code).toBe(4902);
expect(bg.walletState().networkId).toBe("mainnet");
expect(bg.walletState()).toEqual(before);
expect(bg.chainChangedEvents()).toEqual([]);
});
test("a switch by a connected origin keeps the user's endpoint", async () => {
test("a page cannot make the background send chainChanged", async () => {
const bg = loadBackground();
await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
expect(bg.walletState().rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
const reply = bg.send(
{ type: "AUTISTMASK_NETWORK_CHANGED", chainId: SEPOLIA.chainId },
{ url: CONNECTED_ORIGIN + "/" },
);
await settle();
await bg.switchChain(MAINNET.chainId, CONNECTED_ORIGIN);
expect(bg.walletState().rpcUrl).toBe(CUSTOM_RPC);
expect(reply).toEqual({ error: "Unauthorized sender" });
expect(bg.chainChangedEvents()).toEqual([]);
});
});
+44 -2
View File
@@ -14,6 +14,10 @@
// itself: the handler has to do it. tests/chainSwitchGate.test.js mocks the
// state module wholesale and tests/networkEndpoints.test.js always loads
// 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 { makeStorageStub } = require("./support/storageStub");
@@ -90,6 +94,9 @@ function loadColdWorker(networkId) {
const storage = makeStorageStub({ autistmask: storedProfile(networkId) });
let messageListener = null;
let connectListener = null;
// The URL of every approval window opened; the approval id is in it.
const opened = [];
const toTabs = [];
global.chrome = {
@@ -101,12 +108,19 @@ function loadColdWorker(networkId) {
messageListener = fn;
},
},
onConnect: { addListener: () => {} },
onConnect: {
addListener: (fn) => {
connectListener = fn;
},
},
lastError: null,
},
windows: {
getLastFocused: (cb) => cb(null),
create: (options, cb) => cb({ id: 1 }),
create: (options, cb) => {
opened.push(options.url);
cb({ id: opened.length });
},
remove: (id, cb) => {
if (cb) cb();
},
@@ -124,6 +138,32 @@ function loadColdWorker(networkId) {
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) {
let result = null;
messageListener(
@@ -138,6 +178,8 @@ function loadColdWorker(networkId) {
},
);
await settle();
approvePrompt();
await settle();
return result;
}
+259
View File
@@ -0,0 +1,259 @@
// A transfer is sent only on the network its confirmation screen was drawn for
// (https://git.eeqj.de/sneak/AutistMask/issues/524).
//
// Settings is reachable from the confirmation screen, and Back from it only
// unhides the screen, still labelled for the network it was drawn for. Sign &
// Send used to take the network at the moment of the click, so 1 SepoliaETH
// reviewed on Sepolia went out as 1 ETH on mainnet once mainnet was chosen in
// Settings in between.
//
// Driven through the real confirmation screen, signer and chain switch, with
// only the node and the DOM stubbed. The stub node is on the network its
// provider was built for, so a broadcast records the network it went to.
"use strict";
// The signed transactions the stub node was handed, with its network's chain id.
const mockNode = { broadcast: [] };
jest.mock("ethers", () => {
const actual = jest.requireActual("ethers");
class StubProvider {
constructor(url, network) {
this.network = network;
}
async getNetwork() {
return this.network;
}
async getFeeData() {
return {
maxFeePerGas: 20000000000n,
maxPriorityFeePerGas: 1000000000n,
gasPrice: 10000000000n,
};
}
async estimateGas() {
return 21000n;
}
async getCode() {
return "0x";
}
async getTransactionCount() {
return 1;
}
async lookupAddress() {
return null;
}
async broadcastTransaction(signed) {
mockNode.broadcast.push({ chainId: this.network.chainId, signed });
return { hash: "0x" + "ab".repeat(32) };
}
}
return { ...actual, JsonRpcProvider: StubProvider };
});
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
debugFetch: jest.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ items: [] }),
})),
urlOrigin: () => "",
setRuntimeDebug: () => {},
isDebug: () => false,
}));
// The wait screen polls for a receipt; these tests stop at the broadcast.
jest.mock("../src/popup/views/txStatus", () => ({
showWait: jest.fn(),
showError: jest.fn(),
}));
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
// A stub DOM: every id resolves to a recording element.
const elements = new Map();
function makeEl(id) {
const handlers = new Map();
return {
id,
textContent: "",
innerHTML: "",
value: "",
disabled: false,
style: {},
dataset: {},
classList: {
add() {},
remove() {},
toggle() {},
contains: () => false,
},
handlers,
addEventListener(name, fn) {
handlers.set(name, fn);
},
querySelectorAll: () => [],
querySelector: () => null,
remove() {},
focus() {},
};
}
global.document = {
getElementById(id) {
if (!elements.has(id)) elements.set(id, makeEl(id));
return elements.get(id);
},
createElement: (tag) => makeEl(tag),
body: { prepend() {}, appendChild() {} },
addEventListener() {},
};
global.navigator = { clipboard: { writeText() {} } };
const { BaseWallet, Transaction, Wallet } = require("ethers");
const { encryptWithPassword } = require("../src/shared/vault");
const { state, loadState } = require("../src/shared/state");
const { onChainSwitch } = require("../src/shared/chainSwitch");
const { NETWORKS } = require("../src/shared/networks");
const txStatus = require("../src/popup/views/txStatus");
const confirmTx = require("../src/popup/views/confirmTx");
const PRIVATE_KEY = "0x" + "11".repeat(32);
const HOLDER = new Wallet(PRIVATE_KEY).address;
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const PASSWORD = "correct horse battery staple";
function el(id) {
return global.document.getElementById(id);
}
let encryptedSecret;
let signTransaction;
beforeAll(async () => {
await loadState();
encryptedSecret = await encryptWithPassword(PRIVATE_KEY, PASSWORD);
});
beforeEach(() => {
elements.clear();
mockNode.broadcast = [];
txStatus.showWait.mockClear();
txStatus.showError.mockClear();
signTransaction = jest.spyOn(BaseWallet.prototype, "signTransaction");
});
afterEach(() => {
signTransaction.mockRestore();
});
// Show the confirmation screen for 1 of the native token on `networkId`, as
// Review on the Send screen does, with its fee estimate settled.
async function review(networkId) {
await onChainSwitch(networkId);
state.wallets = [
{
type: "key",
name: "Wallet 1",
encryptedSecret,
addresses: [{ address: HOLDER, balance: "5.0" }],
},
];
state.selectedWallet = 0;
state.selectedAddress = 0;
confirmTx.init({});
confirmTx.show({
from: HOLDER,
to: RECIPIENT,
ensName: null,
amount: "1",
token: "ETH",
balance: "5.0",
tokenSymbol: null,
tokenBalance: null,
tokenDecimals: null,
max: false,
});
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
async function signAndSend() {
el("confirm-tx-password").value = PASSWORD;
await el("btn-confirm-send").handlers.get("click")();
}
test("a transfer reviewed on Sepolia and sent goes out on Sepolia", async () => {
await review("sepolia");
expect(el("confirm-type").textContent).toBe("Native SepoliaETH transfer");
expect(el("btn-confirm-send").disabled).toBe(false);
await signAndSend();
expect(mockNode.broadcast).toHaveLength(1);
expect(mockNode.broadcast[0].chainId).toBe(11155111n);
expect(Transaction.from(mockNode.broadcast[0].signed).chainId).toBe(
11155111n,
);
expect(txStatus.showWait).toHaveBeenCalledTimes(1);
});
describe.each([
["sepolia", "mainnet", "SepoliaETH"],
["mainnet", "sepolia", "ETH"],
])(
"a transfer reviewed on %s, with %s chosen in Settings before Sign & Send",
(reviewedOn, switchedTo, symbol) => {
test("is neither signed nor sent, and the screen says why", async () => {
await review(reviewedOn);
expect(el("confirm-type").textContent).toBe(
"Native " + symbol + " transfer",
);
// Settings' network select, then Back, which only unhides the
// screen as it was drawn.
await onChainSwitch(switchedTo);
await signAndSend();
expect(signTransaction).not.toHaveBeenCalled();
expect(mockNode.broadcast).toEqual([]);
expect(txStatus.showWait).not.toHaveBeenCalled();
expect(txStatus.showError).not.toHaveBeenCalled();
expect(el("confirm-errors").style.visibility).toBe("visible");
expect(el("confirm-errors").innerHTML).toContain(
"The active network changed after this transaction was" +
" reviewed, so it was not sent. Please go back and review" +
" it again.",
);
});
},
);
// The check in the click handler tells the user; the send itself carries the
// chain id the screen was drawn for, so a signer on another network's node
// cannot sign it even if the network changes after that check.
test("the send is refused by a signer on another network's node", async () => {
const { getProvider } = require("../src/shared/balances");
const signer = new Wallet(PRIVATE_KEY).connect(
getProvider("https://rpc.example.invalid", "mainnet"),
);
await expect(
confirmTx.populateVerifyAndSend(signer, {
token: "ETH",
to: RECIPIENT,
amount: "1",
chainId: NETWORKS.sepolia.chainId,
}),
).rejects.toThrow("transaction chainId mismatch");
expect(signTransaction).not.toHaveBeenCalled();
expect(mockNode.broadcast).toEqual([]);
});
+1 -1
View File
@@ -253,7 +253,7 @@ describe("reaching the screen", () => {
const { decryptWithPassword } = require("../src/shared/vault");
decryptWithPassword.mockRejectedValue(new Error("nope"));
await click("btn-delete-wallet-confirm");
expect(node("delete-wallet-flash").textContent).toBe(
expect(node("delete-wallet-password-error").textContent).toBe(
"That password is incorrect. Please try again.",
);
});
+8 -2
View File
@@ -279,12 +279,18 @@ class Driver {
// 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
// the thing a test means by visible.
// the thing a test means by visible. Until the page's stylesheet has
// 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) {
return this.waitFor(
"selector " + selector + " to be visible",
`const el = document.querySelector(arguments[0]);
if (!el) return false;
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],
+166
View File
@@ -63,6 +63,7 @@ const {
const { ConsoleErrors, EXTENSION_ORIGIN, start, sleep } = require("./driver");
const { startDappServer } = require("./dapp");
const { STUB_COUNTERPARTY } = require("../network");
const { NETWORKS } = require("../../../src/shared/networks");
const {
STATE_SCHEMA_VERSION,
stateProblem,
@@ -127,6 +128,18 @@ 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
// 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
@@ -684,6 +697,159 @@ 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(
"eth_sendTransaction shows the transaction and returns its hash",
async (env) => {
+26 -1
View File
@@ -333,8 +333,33 @@ async function launch(routeOpts) {
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) {
await page.waitForSelector(selector, { state: "visible", timeout });
await page
.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
+316 -13
View File
@@ -113,6 +113,18 @@ 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
// 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),
@@ -809,7 +821,7 @@ async function secretScreenState(page, view) {
return page.evaluate(
(v) => ({
value: document.getElementById(v + "-value").textContent,
error: document.getElementById(v + "-flash").textContent,
error: document.getElementById(v + "-password-error").textContent,
html: document.getElementById("view-" + v).innerHTML,
resultHidden: document
.getElementById(v + "-result")
@@ -906,7 +918,8 @@ test("a wrong password reveals nothing (#161)", async (env) => {
await env.page.click("#btn-show-phrase-reveal");
await env.page.waitForFunction(
() =>
document.getElementById("show-phrase-flash").textContent.length > 0,
document.getElementById("show-phrase-password-error").textContent
.length > 0,
null,
{ timeout: 60000 },
);
@@ -1993,13 +2006,14 @@ 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.
// The two on the dApp approval screens also have a border and padding, which
// that reserved height has to cover too.
// that reserved height has to cover too. The add wallet screen's line sits
// beside its button rather than above it, and has its own test below.
const PASSWORD_ERROR_CONTAINERS = [
"approve-tx-error",
"approve-sign-error",
"export-privkey-flash",
"show-phrase-flash",
"delete-wallet-flash",
"export-privkey-password-error",
"show-phrase-password-error",
"delete-wallet-password-error",
"confirm-tx-password-error",
];
@@ -2064,6 +2078,107 @@ 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)
//
// The screen that decides what gets signed. The arithmetic underneath it
@@ -3396,7 +3511,7 @@ async function closeApprovalPages(ctx) {
}
// Click a button whose own handler closes the window it lives in — every
// Reject, and Allow on the site prompt.
// Reject, Allow on the site prompt, and Switch on the network switch prompt.
//
// page.click() dispatches the click and then waits for the renderer to
// acknowledge it, and a page torn down by the handler never gets to. The
@@ -3411,12 +3526,13 @@ async function closeApprovalPages(ctx) {
// #btn-reject-sign, #btn-reject-tx — their disconnect leaves the approval
// pending, so a click that never landed leaves the dApp promise unsettled
// and the assertion after the call fails on its own.
// #btn-approve — only a decision resolves the promise, and a swallowed click
// cannot produce settled === "resolved".
// #btn-reject on the site prompt — NOT self-proving. A page that went away
// without the click landing disconnects the approval port, the background
// settles that as 4001, and 4001 is exactly what assertUserRejection
// accepts. Both call sites arm the click trace below and assert it.
// #btn-approve, #btn-approve-network — only a decision resolves the promise,
// and a swallowed click cannot produce settled === "resolved".
// #btn-reject on the site prompt, #btn-reject-network — NOT self-proving. A
// page that went away without the click landing disconnects the approval
// port, the background settles that as 4001, and 4001 is exactly what
// assertUserRejection accepts. Every call site arms the click trace below
// and asserts it.
//
// A button that is missing or unclickable raises a different error, which is
// rethrown.
@@ -3741,6 +3857,9 @@ test("eth_requestAccounts approved returns the selected address (#183)", async (
shown.toLowerCase().includes(env.expectedAddress.toLowerCase()),
"the site prompt shows the wrong address: " + JSON.stringify(shown),
);
// The text above is there even when the row is too narrow to show it
// all; this measures that the whole address is visible (#517).
await assertAddressesFit(popup, "the connection prompt");
// Remembered, so the connection survives a background worker that MV3
// terminates after 30 seconds idle. The in-memory connectedSites map
@@ -4286,6 +4405,190 @@ test("a prompt raised while another approval window has focus opens its own (#29
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
// 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
+102
View File
@@ -0,0 +1,102 @@
// The sign screen signs an eth_sign request the way it signs personal_sign: as
// an EIP-191 personal message over the bytes of the data
// (https://git.eeqj.de/sneak/AutistMask/issues/409). Booted through the real
// popup entry point as the approval window. The background's half, which reads
// the address first and the data second and checks the signature, is tested in
// tests/backgroundApproval.test.js.
// Mocked so that no password has to be hashed: the stored secret decrypts to
// the test key below.
jest.mock("../src/shared/vault", () => ({
decryptWithPassword: jest.fn(),
encryptWithPassword: jest.fn(),
}));
const { Wallet, getBytes } = require("ethers");
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
} = require("./support/popupBoot");
// A published test key, never used for real funds.
const SIGNER_KEY =
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
const signer = new Wallet(SIGNER_KEY);
// "Hello AutistMask", as the hex a page sends.
const MESSAGE = "0x48656c6c6f204175746973744d61736b";
const ORIGIN = "https://dapp.example";
afterEach(cleanupPopup);
test("the sign screen answers eth_sign with a personal message signature over the data", async () => {
const sent = [];
const page = await bootPopup(
unversionedValidProfile({
wallets: [
{
type: "key",
name: "Wallet 1",
encryptedSecret: "encrypted-key",
addresses: [
{
address: signer.address,
balance: "0.0000",
tokenBalances: [],
},
],
},
],
activeAddress: signer.address,
allowedSites: { [signer.address]: [ORIGIN] },
}),
{
search: "?approval=approval-1",
runtime: {
connect: () => ({ postMessage: () => {} }),
// Answered as the background answers: with the eth_sign
// approval this window was opened for, and by handing back
// the signature the popup sends.
sendMessage: (msg, reply) => {
sent.push(msg);
if (msg.type === "AUTISTMASK_GET_APPROVAL") {
reply({
type: "sign",
origin: ORIGIN,
isPhishingDomain: false,
approvedFrom: signer.address,
signParams: {
method: "eth_sign",
message: MESSAGE,
from: signer.address,
},
});
} else if (msg.type === "AUTISTMASK_SIGN_RESPONSE") {
reply({ signature: msg.signature });
} else {
reply(undefined);
}
},
},
},
);
globalThis.window.close = jest.fn();
const { decryptWithPassword } = require("../src/shared/vault");
decryptWithPassword.mockResolvedValue(SIGNER_KEY);
expect(page.visibleViews()).toEqual(["approve-sign"]);
page.node("approve-sign-password").value = "any password";
await page.click("btn-approve-sign");
expect(
sent.filter((msg) => msg.type === "AUTISTMASK_SIGN_RESPONSE"),
).toEqual([
{
type: "AUTISTMASK_SIGN_RESPONSE",
id: "approval-1",
approved: true,
signature: await signer.signMessage(getBytes(MESSAGE)),
},
]);
expect(globalThis.window.close).toHaveBeenCalled();
});
+6 -4
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
// the wipe would restore the flash line on a screen the user has left.
// the wipe would restore the error line on a screen the user has left.
test("never writes the failure message either", async () => {
const { helpers, vault, exportPrivkey } = load();
exportPrivkey.show(0, 0);
@@ -217,8 +217,10 @@ describe("a decrypt still running when the screen is left", () => {
reveal.reject(new Error("decryption failed"));
await reveal.pending;
expect(node("export-privkey-flash").textContent).toBe("");
expect(node("export-privkey-flash").style.visibility).toBe("hidden");
expect(node("export-privkey-password-error").textContent).toBe("");
expect(node("export-privkey-password-error").style.visibility).toBe(
"hidden",
);
});
});
@@ -260,7 +262,7 @@ describe("a reveal that is not interrupted", () => {
await reveal.pending;
expect(node("export-privkey-value").textContent).toBe("");
expect(node("export-privkey-flash").textContent).toBe(
expect(node("export-privkey-password-error").textContent).toBe(
"That password is incorrect. Please try again.",
);
});
+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
// transaction is on, which need not be the active one. A site can switch the
// active network after its transaction is prepared and back before it is
// signed, and a popup opened after a switch shows a sent or listed transaction
// again. The wallet's balances follow the active network; these do not.
// transaction is on, which need not be the active one. The active network can
// change, in Settings or when the user approves a site's request, after a
// transaction is prepared and change back before it is signed, and a popup
// opened after a switch shows a sent or listed transaction again. The wallet's balances follow the active network; these do not.
describe.each([
["mainnet", "sepolia", "ETH"],
["sepolia", "mainnet", "SepoliaETH"],
+159
View File
@@ -0,0 +1,159 @@
// 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);
});
});
+13
View File
@@ -89,6 +89,12 @@ describe("vendored blocklist", () => {
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", () => {
expect(isPhishingDomain("")).toBe(false);
expect(isPhishingDomain(null)).toBe(false);
@@ -133,6 +139,13 @@ describe("hostnameVariants", () => {
test("lowercases hostnames", () => {
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", () => {
+6 -1
View File
@@ -238,6 +238,10 @@ async function settle() {
* @param {object} [options]
* @param {object} [options.storage] a storage stub from makeStorageStub(), for
* 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.
*/
async function bootPopup(stored, options) {
@@ -281,12 +285,13 @@ async function bootPopup(stored, options) {
sendMessage: jest.fn(async () => ({})),
getURL: (p) => "chrome-extension://autistmask/" + p,
onMessage: { addListener: () => {} },
...(options && options.runtime),
},
};
globalThis.document = document;
globalThis.window = {
location: {
search: "",
search: (options && options.search) || "",
href: "chrome-extension://autistmask/src/popup/index.html",
reload: () => reloads.push(Date.now()),
},
+176
View File
@@ -0,0 +1,176 @@
// The decoded details on the transaction detail screen of a token transfer
// (https://git.eeqj.de/sneak/AutistMask/issues/515).
//
// A history row for a token transfer holds the transfer's recipient as its
// `to`, not the contract the transaction was sent to. The screen used to decode
// the transaction's data against that `to`, so its Token line named the
// recipient, and the amount read "decimals unknown" for a token the wallet
// tracks. It decodes against the address the explorer reports the transaction
// was sent to.
//
// The row is the one the real history parser makes from the explorer's answer,
// opened through transactionDetail.show(), which is what a click on the row
// calls. Driven against a minimal DOM stub in the shape
// tests/contractCreation.test.js uses.
const { Interface } = require("ethers");
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
debugFetch: async (url) => ({
ok: true,
json: async () => mockExplorerAnswer(url),
}),
setRuntimeDebug: () => {},
isDebug: () => false,
}));
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { state } = require("../src/shared/state");
const { fetchRecentTransactions } = require("../src/shared/transactions");
const transactionDetail = require("../src/popup/views/transactionDetail");
const EXPLORER = "https://explorer.example/api/v2";
const HOLDER = "0x0000000000000000000000000000000000000a11";
const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD";
// A token the wallet tracks and the bundled token list does not name.
const TOKEN = "0x74de5d4FCbf63E00296fd95d33236B9794016631";
const TX_HASH =
"0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84";
// HOLDER sends 1234.5 of the 6-decimal token to RECIPIENT.
const TRANSFER_DATA = new Interface([
"function transfer(address to, uint256 amount)",
]).encodeFunctionData("transfer", [RECIPIENT, 1234500000n]);
// The explorer's answers: the transfer in HOLDER's token transfers, and the
// transaction itself, sent to the token contract.
function mockExplorerAnswer(url) {
if (
url ===
EXPLORER + "/addresses/" + HOLDER + "/token-transfers?type=ERC-20"
) {
return {
items: [
{
transaction_hash: TX_HASH,
block_number: 100,
timestamp: "2026-10-01T00:00:00Z",
from: { hash: HOLDER },
to: { hash: RECIPIENT },
total: { value: "1234500000", decimals: "6" },
token: {
address_hash: TOKEN,
symbol: "TDOL",
holders_count: "5000",
},
},
],
};
}
if (url === EXPLORER + "/transactions/" + TX_HASH) {
return { to: { hash: TOKEN }, raw_input: TRANSFER_DATA };
}
return { items: [] };
}
function makeElement(id) {
const classes = new Set();
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
style: {},
dataset: {},
classList: {
add: (...names) => names.forEach((n) => classes.add(n)),
remove: (...names) => names.forEach((n) => classes.delete(n)),
contains: (n) => classes.has(n),
toggle: () => false,
},
addEventListener: () => {},
querySelectorAll: () => [],
appendChild: () => {},
};
// Views reach for .parentElement to hide whole sections.
Object.defineProperty(el, "parentElement", {
get: () => node(id + "-parent"),
});
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
// The debug banner is created on demand by helpers.js; absent
// is the state a non-debug, non-testnet popup is in.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
// One line of the decoded details: its label and what is drawn under it.
function decodedLine(label) {
return node("tx-detail-calldata-details")
.innerHTML.split('<div class="mb-2">')
.find((line) =>
line.startsWith(`<div class="text-muted">${label}</div>`),
);
}
beforeEach(() => {
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } };
state.blockscoutUrl = EXPLORER;
state.wallets = [
{
name: "Main",
type: "key",
addresses: [{ address: HOLDER, balance: "0.0000" }],
},
];
state.trackedTokens = [{ address: TOKEN, symbol: "TDOL", decimals: 6 }];
state.viewData = {};
state.viewStack = [];
state.currentView = null;
});
test("a token transfer's decoded details name the token contract and the amount at its scale", async () => {
const [row] = await fetchRecentTransactions(HOLDER, EXPLORER, 1);
expect(row.to).toBe(RECIPIENT);
expect(row.contractAddress).toBe(TOKEN.toLowerCase());
transactionDetail.show(row);
// The decoded details are drawn once the explorer has answered.
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
expect(node("tx-detail-calldata-action").textContent).toBe(
"Token Transfer",
);
const token = decodedLine("Token");
expect(token).toContain('<div class="font-bold">TDOL</div>');
expect(token).toContain(TOKEN);
expect(token).not.toContain(RECIPIENT);
expect(decodedLine("Recipient")).toContain(RECIPIENT);
expect(decodedLine("Amount")).toContain(
'<div class="font-bold">1234.5000 TDOL</div>',
);
});
+131 -1
View File
@@ -233,6 +233,26 @@ function warningOf(data) {
return html.slice(0, html.indexOf(">Domain<"));
}
const NOT_SIGNED = ">Not part of the signature<";
// The Message section alone, without the keys listed after it as not part of
// the signature.
function messageOf(data) {
const html = formatTypedDataHtml(JSON.stringify(data));
const end = html.indexOf(NOT_SIGNED);
return html.slice(
html.indexOf(">Message<"),
end === -1 ? html.length : end,
);
}
// The keys listed as not part of the signature, or "" when there are none.
function notSignedOf(data) {
const html = formatTypedDataHtml(JSON.stringify(data));
const start = html.indexOf(NOT_SIGNED);
return start === -1 ? "" : html.slice(start);
}
function request(data) {
return { method: "eth_signTypedData_v4", typedData: JSON.stringify(data) };
}
@@ -240,6 +260,7 @@ function request(data) {
beforeEach(() => {
state.trackedTokens = [];
state.wallets = [];
state.networkId = "mainnet";
});
describe("a token permission is warned about, naming spender and amount", () => {
@@ -311,7 +332,8 @@ describe("the warning reads only the fields the signed type declares", () => {
});
// A Permit whose type names its spender `operator`; the page adds a
// `spender` key the type does not declare.
// `spender` key the type does not declare. The Message section names
// only the operator, and the added key is listed apart as not signed.
test("an added key is not named as the spender", () => {
const data = {
...PERMIT,
@@ -330,6 +352,10 @@ describe("the warning reads only the fields the signed type declares", () => {
const warning = warningOf(data);
expect(warning).toContain(WARNING);
expect(warning).not.toContain(DECOY);
expect(messageOf(data)).toContain(SPENDER);
expect(messageOf(data)).not.toContain(DECOY);
expect(notSignedOf(data)).toContain("spender:");
expect(notSignedOf(data)).toContain(DECOY);
});
// The permit for a Uniswap v3 position NFT: a `Permit` with no amount
@@ -370,6 +396,47 @@ describe("the warning reads only the fields the signed type declares", () => {
});
});
// ethers signs only the declared fields at every level of nesting, so an
// added key inside a struct is not signed either. Such typed data is not
// refused; the added key is listed apart, with where it sits in the message.
describe("the Message section shows only the fields that are signed", () => {
const NOTE = "This signature only signs you in";
test("an added key inside a nested struct is listed as not signed", () => {
const data = {
...PERMIT_SINGLE,
message: {
...PERMIT_SINGLE.message,
details: { ...PERMIT_SINGLE.message.details, note: NOTE },
},
};
expect(messageOf(data)).toContain("expiration");
expect(messageOf(data)).not.toContain(NOTE);
expect(notSignedOf(data)).toContain("details.note:");
expect(notSignedOf(data)).toContain(NOTE);
expect(typedDataRefusal(request(data))).toBeNull();
});
test("an added key inside a list item is listed with its index", () => {
const [first, second] = PERMIT_BATCH.message.details;
const data = {
...PERMIT_BATCH,
message: {
...PERMIT_BATCH.message,
details: [first, { ...second, note: NOTE }],
},
};
expect(messageOf(data)).not.toContain(NOTE);
expect(notSignedOf(data)).toContain("details[1].note:");
expect(notSignedOf(data)).toContain(NOTE);
});
test("nothing is listed apart when every key is declared", () => {
expect(notSignedOf(PERMIT_SINGLE)).toBe("");
expect(notSignedOf(LOGIN)).toBe("");
});
});
describe("the primary type shown is the one ethers signs", () => {
// A drain stated as a sign-in: the page says Login, the types say
// PermitSingle, and ethers would sign PermitSingle.
@@ -532,3 +599,66 @@ describe("the sign screen refuses a mismatched primary type", () => {
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);
});
});
+49 -5
View File
@@ -416,7 +416,7 @@ describe("extended key depth", () => {
expect(() =>
wallet.getSignerForAddress(
{ type: "xprv" },
0,
{ address: VECTOR_ADDRESSES[0], index: 0 },
accountXprv(VECTOR_PHRASE),
),
).toThrow(/master/i);
@@ -504,7 +504,7 @@ describe("getSignerForAddress", () => {
test.each([0, 1, 2])("hd wallet, address index %i", (index) => {
const signer = wallet.getSignerForAddress(
{ type: "hd" },
index,
{ address: VECTOR_ADDRESSES[index], index },
VECTOR_PHRASE,
);
@@ -515,7 +515,7 @@ describe("getSignerForAddress", () => {
test.each([0, 1, 2])("xprv wallet, address index %i", (index) => {
const signer = wallet.getSignerForAddress(
{ type: "xprv" },
index,
{ address: VECTOR_ADDRESSES[index], index },
masterXprv(VECTOR_PHRASE),
);
@@ -527,7 +527,7 @@ describe("getSignerForAddress", () => {
for (const index of [0, 1, 2]) {
const signer = wallet.getSignerForAddress(
{ type: "privkey" },
index,
{ address: VECTOR_ADDRESSES[1], index },
VECTOR_PRIVATE_KEYS[1],
);
@@ -535,10 +535,54 @@ describe("getSignerForAddress", () => {
}
});
// https://git.eeqj.de/sneak/AutistMask/issues/510: an address whose stored
// index does not derive it, or a private key that is not its key.
test.each([
["hd", { address: VECTOR_ADDRESSES[2], index: 1 }, VECTOR_PHRASE],
[
"xprv",
{ address: VECTOR_ADDRESSES[2], index: 1 },
masterXprv(VECTOR_PHRASE),
],
["privkey", { address: VECTOR_ADDRESSES[2] }, VECTOR_PRIVATE_KEYS[1]],
])(
"%s wallet refuses a key that is not the address's own",
(type, addr, secret) => {
expect(() =>
wallet.getSignerForAddress({ type }, addr, secret),
).toThrow(
"The key this wallet holds for " +
VECTOR_ADDRESSES[2] +
" belongs to a different address, so it was not used.",
);
},
);
// An HD or xprv address record without a usable `index` is refused in a
// sentence, not with what ethers says about the value it was handed.
test.each([
["hd", VECTOR_PHRASE],
["xprv", masterXprv(VECTOR_PHRASE)],
])("%s wallet refuses an address with no valid index", (type, secret) => {
for (const index of [undefined, null, -1, 1.5, "1"]) {
expect(() =>
wallet.getSignerForAddress(
{ type },
{ address: VECTOR_ADDRESSES[1], index },
secret,
),
).toThrow(
"No derivation index is stored for " +
VECTOR_ADDRESSES[1] +
", so its key cannot be derived.",
);
}
});
test("the returned signer signs recoverably as the expected address", async () => {
const signer = wallet.getSignerForAddress(
{ type: "hd" },
1,
{ address: VECTOR_ADDRESSES[1], index: 1 },
VECTOR_PHRASE,
);
const message = "AutistMask derivation test";
+20 -4
View File
@@ -55,7 +55,7 @@ function healthyXprvWallet(name = "Master") {
xpub,
nextIndex: 1,
encryptedSecret: "irrelevant-to-these-tests",
addresses: [{ address: firstAddress, balance: "0.0000" }],
addresses: [{ address: firstAddress, index: 0, balance: "0.0000" }],
};
}
@@ -74,7 +74,11 @@ function brokenXprvWallet(name = "Imported xprv") {
nextIndex: 1,
encryptedSecret: "irrelevant-to-these-tests",
addresses: [
{ address: node.deriveChild(0).address, balance: "0.0000" },
{
address: node.deriveChild(0).address,
index: 0,
balance: "0.0000",
},
],
};
}
@@ -153,6 +157,18 @@ describe("the explanatory copy", () => {
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", () => {
const body = defect.paragraphs.join(" ");
@@ -558,7 +574,7 @@ describe("no path throws an unhandled error for a defective wallet", () => {
try {
wallet.getSignerForAddress(
{ type: "xprv" },
0,
brokenXprvWallet().addresses[0],
accountXprv(VECTOR_PHRASE),
);
} catch (e) {
@@ -573,7 +589,7 @@ describe("no path throws an unhandled error for a defective wallet", () => {
test("a healthy xprv wallet signs as it always did", () => {
const signer = wallet.getSignerForAddress(
{ type: "xprv" },
0,
healthyXprvWallet().addresses[0],
masterXprv(VECTOR_PHRASE),
);