14 Commits
Author SHA1 Message Date
clawbot d2fd0f532a fix: a popup that opened on Welcome keeps refreshing once the first wallet is added (closes #530)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The ten-second refresh was started only by init(), and only when a wallet
existed as the popup opened, so a popup that opened on Welcome refreshed the
new wallet once and then showed that balance until it was reopened. init()
and the three import paths on AddWallet now call startRefresh(), which
refreshes at once and starts the ten-second refresh unless it is already
running, and does nothing while the recovery screen is up. The two AddWallet
tests that build their own context pass it in place of the refresh they
passed before.

Model: opus-5-5
2026-10-19 03:19:53 +02:00
clawbot b923b2c19c fix: a V4 step of chained swap actions shows what the user receives (closes #563)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A V4 step's input side is what SETTLE or SETTLE_ALL names, else the one
currency its swap actions spend and never buy; its output side is what
TAKE or TAKE_ALL names, else the one they buy and never spend. Amount sums
the amountIn of the actions spending the input side, shown only when each
states one; Min. received likewise. An unread swap action leaves the sides
to SETTLE, SETTLE_ALL, TAKE and TAKE_ALL, with no figure. A step with a
swap action always sets the output side, sets the input side only if no
earlier step did, and leaves a side its rules cannot name unknown, with no
figure. SWAP_EXACT_OUT spends its first path entry.

Model: opus-5-5
2026-10-19 02:50:16 +02:00
clawbot e2e1c5d40e fix: the recipient warning says what it checked and skips addresses that have received ETH (closes #528)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The transaction count the node reports is what an address has sent, so
an address that only receives, such as a savings address, got the "ZERO
transaction history" box on every send. The check now also asks the node
for the recipient's balance and warns only when both are zero. The box
says the address has never sent a transaction and holds no ETH, naming
the active network's native token as the contract warning does.

Model: opus-5-5
2026-10-19 02:34:13 +02:00
clawbot fc042c3191 fix: a page whose origin has no host is never told an address by accountsChanged (closes #564)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The background judges each tab by its URL's origin, the only one it can see, so a page its server sandboxes, whose own origin is null, was still sent the new active address on an address switch although all its requests are refused.

The background now names the origin it judged by on each accountsChanged event, and the content script passes the address to the page only when the page's own window.origin is that origin, an empty list otherwise. An event naming no origin also reaches the page as an empty list. chainChanged is unchanged. The README's SiteApproval section says the same.

Judgement call: covered by a unit test that sets window.origin; no browser-suite test.

Model: opus-5-5
2026-10-19 00:59:39 +02:00
clawbot 8b0a1bff02 fix: a site allowed without Remember stays connected when the background worker idles (closes #526)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A connection allowed without "Remember my choice for this site" lived in a module-level object in the background, so it ended the first time Chrome stopped the idle worker. Each such connection is now one item in the browser's session storage, which outlives the worker and is emptied when the browser closes, read and written through new wrappers in src/shared/browserApi.js. Removal in Settings, address switch, address removal and wallet removal still end it; Settings now waits for the removal before redrawing.

manifest/firefox.json now declares a minimum of Firefox 115.0, the first with session storage.

Judgement call: address and wallet removal still notify the background without waiting.

Model: opus-5-5
2026-10-18 23:59:24 +02:00
clawbot 1f5ec1c8de fix: Send refuses every amount the confirmation screen would refuse (closes #532)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Send's Review accepted any amount parseFloat() read as above zero, so 1e-3, 1abc and amounts with too many decimal places reached ConfirmTx, which then refused them or failed to estimate the fee.

isValidAmount() in src/shared/txValidation.js is now the one amount check, used by Send's Review and Max and by validateTransfer() for ConfirmTx: plain digits with at most one decimal point, above zero, nothing but zeros past the token's places. Zeros past those places are accepted, since the transfer encodes the amount exactly. Send now reads the token's decimal places before the ENS lookup, because the check needs them.

Judgement call: ConfirmTx now also refuses zero, as Send already did.

Model: opus-5-5
2026-10-18 22:46:07 +02:00
clawbot f00954e2c5 fix: failed requests reach the page with an error code and the node's data (closes #533)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A request the node refuses now reaches the page with the node's own JSON-RPC code and data: a reverting eth_call, an eth_sendTransaction whose gas estimate reverts, a failed broadcast. Every other refusal from the background carries a code too: 4100 when there is no address, -32603 for a failure inside the wallet, -32602 for a transaction request the wallet refuses as malformed. On the transaction path the page keeps the wallet's own sentence.

nodeError(), which finds the node's error inside an ethers error, lives in src/shared/approvalVerify.js beside failureTexts(), which did the same lookup.

Not run in a browser.

Model: opus-5-5
2026-10-18 22:12:10 +02:00
clawbot e48308c5b6 fix: the first wallet's first address is active as soon as it is added (closes #512)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
The popup picked an active address only when it opened, so after the first
wallet was created nothing was active until a reopen: Home had no headline,
and Send and Receive refused. Each of the three import paths on AddWallet now
makes the new wallet's first address active when no address is. When the
phrase or xprv scan then replaces the address list with one that leaves that
address out, the new list's first address becomes active and the background
is told, as when the user picks an address on Home. The address scan stand-in
in the popup test boot now answers in the real scan's shape, which the phrase
and xprv imports read.

Model: opus-5-5
2026-10-16 07:15:29 +02:00
clawbot aa10a2a5f5 fix: each browser-suite run builds and removes an image of its own (closes #547)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
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-16 06:51:08 +02:00
clawbot 2c0888e112 fix: approval windows open as wide as the page (closes #518)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
The approval window opened 360 pixels wide while the page is 396, cutting
off the Reject button; it now opens at 396. The browser counts the
window's frame in that width, and a tall screen gives some of it to a
vertical scrollbar, so once the approval's screen is up the page widens
its own window by however far it would scroll sideways, times the zoom of
its own tab. Firefox draws some zoom steps a little larger than it
reports, so the page measures again after each widening, three times at
most. The toolbar popup's window is left alone. The Firefox suite checks
that the sign prompt fits at 170%. README's Visual Style line now gives
the page's real width.

Model: opus-5-5
2026-10-16 05:44:50 +02:00
clawbot f857bb0a3f fix: a request whose origin has no host is refused, not treated as a site (closes #527)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
A page with an opaque origin, such as one its server sandboxes with a
Content-Security-Policy, is reported to the background with the origin
"null", and Chrome reports a local file as "file://". The background took
either as a site, so every such page was one site: a connection remembered
for one covered them all, and the phishing check never matched. The
background now parses the reported origin and, when it has no host, refuses
the request with 4100 like one with no origin, before any method runs.
tests/rpcOrigin.test.js sends both, and README's SiteApproval says what is
refused.

Model: opus-5-5
2026-10-16 04:44:49 +02:00
clawbot 70a41ed70e fix: typed data with a missing field or a bool that is not true or false is refused (closes #552)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
ethers signs a bool field by whether its value is truthy, so the string
"false" was shown as false and signed as true, and a bool field left out
was shown as nothing and signed as false. The sign screen now refuses
typed data whose message leaves out a declared field, at any level of
nesting, or holds a bool that is not true or false: the error line names
the field and Sign is disabled. The background refuses a signature for it
too. Presence is an own-key test, so a field named like a built-in
property such as constructor is not found by accident. signedPrimaryType()
moves to the shared module so the background reads the message as the
same type.

Model: opus-5-5
2026-10-16 03:33:39 +02:00
clawbot f9f32ab32d fix: a slow answer to an approval still reaches the site (closes #540)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
Chrome stopped the background worker about six minutes after a site's
request even with the approval window open, and the approval and the
site's waiting request went with it. Every approval is now added through
addPendingApproval(), which starts a timer in the worker that calls
runtime.getPlatformInfo() every 20 seconds while any approval is
pending; Chrome counts each call as activity.

tests/e2e/slowApproval.js, run by make test-e2e, leaves a transaction
and a signature approval open for seven minutes in minimized windows and
requires the site to receive both results. It drives Chromium over the
DevTools pipe because Playwright keeps DevTools attached to the worker.
Its loopback dApp server, shared with the Firefox suite, moves to
tests/e2e/dapp.js.

Model: opus-5-5
2026-10-16 03:27:53 +02:00
clawbot bad6494e43 fix: a V4 swap action not in the deployed shape is shown as not decoded (closes #358)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
The four V4 swap actions are read only as the structs deployed on mainnet
and Sepolia. ethers read the newer v4-periphery structs, which add
minHopPriceX36, as the deployed ones, and the newer multi-hop exact-in
struct showed the offset of its hop-price array as the swap amount. A
decode now counts only when encoding it again gives back the same bytes.
A swap action that fails that marks its step "V4 Swap (could not be
decoded)", and a side its SETTLE or TAKE did not name reads "Unknown
(could not be decoded)". The wait and error screens no longer show the
ETH value as the amount of a swap whose amount was not read, unless the
swap is paid in native ETH.

Model: opus-5-5
2026-10-16 03:00:32 +02:00
50 changed files with 4745 additions and 488 deletions
+2 -2
View File
@@ -35,10 +35,10 @@ on: [push]
jobs:
e2e-chrome:
runs-on: ubuntu-latest
# Bounds the image build, a cold cache included, and both Chrome
# Bounds the image build, a cold cache included, and the three Chrome
# programs, so a hung browser frees the shared runner. README.md
# "In CI" has the measured times.
timeout-minutes: 20
timeout-minutes: 40
steps:
# actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+192 -55
View File
@@ -362,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
@@ -479,6 +487,9 @@ papered over:
forced by clicking Reveal and the settings gear in one page task, which a
person cannot do; the case a person can hit, the first decrypt after the popup
opens while libsodium is still loading, is not driven.
- Approval windows open at 1280x720 whatever size the extension asks for, so
whether an approval screen fits its window is checked in the Firefox suite
only.
Any test that drives a failure path on purpose declares the `console.error` it
is about to provoke, via `errors.expect()`. That is not a mute: the declaration
@@ -542,6 +553,31 @@ one profile yields a single extension id, not two: Chrome does not load a second
copy of an id it already has. The assertions are annotated as observations, so a
Chrome that ever changes this fails the run instead of passing it.
Last, `make test-e2e` runs `tests/e2e/slowApproval.js`, which leaves a
transaction approval and a signature approval open for seven minutes with both
windows minimized, then restores and approves both and requires the site to
receive the hash of the transaction the stub node received and a signature that
recovers to the address. Before the background worker kept itself running while
an approval is pending, Chrome stopped it about six minutes in and neither
request was ever answered
([#540](https://git.eeqj.de/sneak/AutistMask/issues/540)). The windows are
minimized through the DevTools protocol (`Browser.setWindowBounds`), and the run
fails unless both pages then report themselves hidden: Chrome slows a hidden
page's timers to once a minute, so anything the window did to keep the worker
running would fail here. It is its own program and does not use Playwright:
Playwright keeps DevTools attached to the background worker, and Chrome never
stops a worker DevTools is attached to, so no wait in the suite above can show
the worker stopping. It starts Chromium itself, speaks the DevTools protocol
over `--remote-debugging-pipe`, and attaches to pages only. Before raising the
approvals it requires the worker to stop on its own with nothing pending, so a
run in which something else keeps the worker running fails. The site is
connected, without "Remember my choice for this site", and the RPC endpoint set
through the extension's own screens, against the loopback dApp page and stub
node the Firefox suite also uses (`tests/e2e/dapp.js`). Once the worker has
stopped, the run requires the site still to be told the address
([#526](https://git.eeqj.de/sneak/AutistMask/issues/526)). Unlike the suite
above, it does not fail on page errors.
### Firefox (`make test-e2e-firefox`)
`make test-e2e-firefox` builds `dist/firefox/` and drives the **real popup in a
@@ -551,13 +587,16 @@ 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.
background page and approval windows. The `personal_sign` prompt is also
required to fit its window without scrolling sideways, with the extension's
pages at 170% zoom.
The suite lives in `tests/e2e/firefox/`. Its WebDriver client (`driver.js`) has
**no npm dependencies at all**: it is built on global `fetch` and
`child_process` against geckodriver's HTTP API. The dApp fixture (`dapp.js`) and
the assertions do use `ethers`, and have to — a signature is recovered in the
runner rather than believed from the extension, and the stub node has to answer
`child_process` against geckodriver's HTTP API. The dApp fixture
(`tests/e2e/dapp.js`, shared with `tests/e2e/slowApproval.js`) and the
assertions do use `ethers`, and have to — a signature is recovered in the runner
rather than believed from the extension, and the stub node has to answer
`eth_sendRawTransaction` with the hash `ethers` computes for the artifact it
sent, or `provider.broadcastTransaction()` refuses the answer.
@@ -663,16 +702,16 @@ Three limits are worth knowing, all real differences from the Chrome suite:
- **Almost nothing is stubbed, which inverts the coverage of network-dependent
code.** The container still runs with `--network none`, so the run is offline
and no request can escape. The one thing it can reach is the loopback fixture
in `tests/e2e/firefox/dapp.js`, which serves the dApp page and a JSON-RPC node
and which the extension's `rpcUrl` is pointed at for the dApp steps; a
JSON-RPC method that fixture does not model fails the run rather than
answering `null`. Everything else, Blockscout and the price feed among it, has
no fixture and simply fails, and the extension swallows its own fetch
failures, so only the _failure_ branches of that code are ever executed. A
`ReferenceError` in the success path of `renderTransactions`, or of price
rendering, passes this suite green. The offline run is also weaker than the
Chrome suite's interception for those calls: it proves nothing got out, but it
cannot report which requests were attempted.
in `tests/e2e/dapp.js`, which serves the dApp page and a JSON-RPC node and
which the extension's `rpcUrl` is pointed at for the dApp steps; a JSON-RPC
method that fixture does not model fails the run rather than answering `null`.
Everything else, Blockscout and the price feed among it, has no fixture and
simply fails, and the extension swallows its own fetch failures, so only the
_failure_ branches of that code are ever executed. A `ReferenceError` in the
success path of `renderTransactions`, or of price rendering, passes this suite
green. The offline run is also weaker than the Chrome suite's interception for
those calls: it proves nothing got out, but it cannot report which requests
were attempted.
- **The site-connection prompt always opens in a window of its own.** The
profile turns off `extensions.openPopupWithoutUserGesture.enabled`, so
`src/background/index.js` falls back from the toolbar popup, which WebDriver
@@ -722,11 +761,13 @@ installing Firefox, geckodriver and their system libraries. Both scripts now
build their image with `--no-cache`, so every run pays the cold figure. Those
`e2e-chrome` runs predate the cases that wait in real time for a receipt to end
in error. `make test-e2e` took 3m51s locally with its image cached, so with the
image rebuilt every run, an `e2e-chrome` run comes to about seven minutes.
image rebuilt every run, an `e2e-chrome` run comes to about seven minutes. The
approvals left open for seven minutes (`tests/e2e/slowApproval.js`) add about
eight more, so about fifteen.
Each e2e job has a `timeout-minutes` cap, so a hung build or browser ends the
job instead of holding the shared runner: `e2e-firefox` 15 minutes and
`e2e-chrome` 20, each over two and a half times the job's slowest cold run. A
`e2e-chrome` 40, each over two and a half times the job's slowest cold run. A
job that reaches its cap has hung; read it as a hang, not as a slow run to
retry. The `check` job has no cap: `.gitea/workflows/check.yml` is the canonical
copy from `sneak/prompts`, kept byte-identical.
@@ -819,10 +860,28 @@ on the next event. Two consequences shape every recurring job in the background:
- Module-level variables do not survive either. Anything that must be remembered
across a restart goes in extension storage. `localStorage` does not exist in a
service worker at all — the one remaining user of it, `src/shared/ens.js`,
runs only in the popup and is marked as such.
runs only in the popup and is marked as such. The sites allowed without
"Remember my choice for this site" are kept in the browser's session storage
(`storage.session`), which outlives the worker but not the browser, so such a
connection lasts until it is ended or the browser closes. Firefox has session
storage from version 115, the minimum `manifest/firefox.json` declares.
The job also carries a freshness guard, and a guard must never be timed to the
alarm period it gates. The guard is measured from the moment the last run
Pending approvals are the exception: an approval waiting for the user, and the
site's request waiting on it, live only in the worker's memory, and only the
worker that received the request can answer it. Left alone, Chrome stopped the
worker about six minutes after a site's request even with the approval window
still open, and the user's answer then reached nobody. So the worker keeps
itself running while any approval is pending: every approval is added through
`addPendingApproval()`, which starts a `setInterval` that calls
`runtime.getPlatformInfo()` every 20 seconds, and Chrome counts each extension
call as activity. The timer stops itself once nothing is pending, and the worker
then stops as it otherwise would. The timer is the worker's own, so it does not
depend on the approval window being visible; a timer in the window would,
because Chrome slows a hidden page's timers to once a minute. A worker that
stops for another reason, a crash say, still loses the approvals open in it.
The balance refresh carries a freshness guard, and a guard must never be timed
to the alarm period it gates. The guard is measured from the moment the last run
finished, which is one run-duration after the alarm that started it, so a guard
of exactly one period vetoes the very next tick and the real cadence becomes two
periods. The balance refresh guard exists to skip work an open popup has already
@@ -908,8 +967,16 @@ discoverable.
enough that vanilla JS with simple view switching is sufficient. A framework
would add bundle size, build complexity, and attack surface for no benefit at
this scale.
- **360x600 popup**: Standard browser extension popup dimensions. The UI is
designed for this fixed viewport.
- **396x600 popup**: The page is 396 pixels wide (`body` in
`src/popup/styles/main.css`), and 600 pixels is as tall as the browser lets
the toolbar popup grow. The UI is designed for this fixed viewport. The window
an approval opens in is given the same size. The browser counts the window's
frame in that width, and a screen taller than the window gives some of it to a
vertical scrollbar, so once the approval's screen is up the page widens its
window by however far it would scroll sideways, scaled by the browser's zoom.
Firefox draws some zoom steps a little larger than the zoom it reports, so the
page measures again once the window has grown, and widens it again while it
still falls short.
#### No Layout Shift
@@ -953,7 +1020,7 @@ on the approval screen, the amount on the wait, success and error screens, the
transaction history and the transaction detail screen, and the refusal of a fee
above 1 ETH. A chain id that names no network reads `ETH`. Wherever this
document shows ETH as the label of a native balance, value or fee, in a "Native
ETH transfer" type line, in the contract-recipient warning or in that refusal,
ETH transfer" type line, in the two recipient warnings or in that refusal,
Sepolia shows `SepoliaETH`. The swap lines keep `ETH`, the router's own name for
the native currency, and the ETH/USD price line, shown on mainnet only, keeps
its fixed wording.
@@ -1101,11 +1168,13 @@ read:
- `All available (V4 open delta)`: the swap's `Amount` line, when the amount it
shows is a V4 exact-in `amountIn` of zero. V4 reads that zero as "use the
whole open delta", so the calldata states no quantity. The line shows the
amount of one step that names an input token or amount: the last
`PERMIT2_PERMIT` step if there is one, otherwise the first V2 or V3 exact-in,
V2 exact-out, `WRAP_ETH` or V4 swap step. A V2 exact-out step gives its
`amountInMax`, and a V4 swap step the `amountIn` of its first readable
exact-in action.
amount of the step that sets the input side: the last `PERMIT2_PERMIT` step if
there is one, otherwise the first V2 or V3 exact-in, V2 exact-out, `WRAP_ETH`
or V4 swap step that names an input token or amount. A V4 step with a swap
action sets the input side when no earlier step set it, even when it names
neither, as described below. A V2 exact-out step gives its `amountInMax`, and
a V4 swap step the `amountIn` of the swap action that spends its input
currency.
- `Whatever an earlier step sent to the pair (V2 already paid)`: the swap's
`Amount` line, when the amount it shows is a V2 exact-in `amountIn` of zero.
The router reads that zero as "the pair already holds the input tokens": the
@@ -1125,8 +1194,34 @@ read:
The swap's `Token In` and `Token Out` lines name a currency, not an amount; each
reads `Unknown (not named in the calldata)` when the decoder found no token for
that side. An `UNWRAP_WETH` step makes `Token Out` ETH only when the output side
is WETH, on mainnet or Sepolia, or when no step set the output side; a WETH
that side, or `Unknown (could not be decoded)` instead when a V4 swap action in
the transaction could not be read (see Transaction Decoding below). A V4 step
with a swap action always sets the output side, and sets the input side only
when no earlier step set it. It finds each side by three rules:
1. Its input side is the currency its `SETTLE` or `SETTLE_ALL` names, or else
the one currency that some swap action in the step spends and none buys. Its
output side is the currency its `TAKE` or `TAKE_ALL` names, or else the one
currency that some swap action buys and none spends. If there is not exactly
one such currency, that side is unknown, and the step sets it as unknown with
no figure. A step that buys WETH with USDT and USDC with that WETH, with
nothing naming its sides, reads `Swap USDT → USDC`.
2. Its `Amount` is the `amountIn` of the swap action that spends the input
currency. If several exact-in actions spend it, each with a stated amount, it
is their sum; in any other case the step gives no `Amount`. The decoder reads
no amount from an exact-out action, and an open delta is not a stated amount.
Its `Min. received` is found the same way, from the `amountOutMin` of the
actions that buy the output currency.
3. If any swap action in the step could not be read, only `SETTLE`,
`SETTLE_ALL`, `TAKE` and `TAKE_ALL` name its sides, and the step gives no
`Amount` or `Min. received`. A side they did not name is unknown, and the
step sets it as unknown with no figure.
A V4 step with no swap action sets only the sides its `SETTLE`, `SETTLE_ALL`,
`TAKE` or `TAKE_ALL` names.
An `UNWRAP_WETH` step makes `Token Out` ETH only when the output side is WETH,
on mainnet or Sepolia, or when no step set the output side; a WETH
`Min. received` figure then reads in ETH. Otherwise `Token Out` and
`Min. received` keep the output side's own token and figure, whether the swap
was paid in ETH or in a token: a V2 exact-out swap that buys USDC and then
@@ -1488,8 +1583,14 @@ view would leave a wallet one click from deletion.
- **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.
**Home**. When no address is active yet, as on the first wallet, the new
wallet's first address becomes the active one. The phrase and xprv modes
then scan for further used addresses and report the count as a flash
message. When the scan finds two or more used addresses, they replace the
wallet's list, which then holds the first address only if it was used. If
the first address was active and is not in the new list, the new list's
first address becomes the active one and `AUTISTMASK_ACTIVE_CHANGED` is
broadcast.
- "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
@@ -1616,7 +1717,13 @@ view would leave a wallet one click from deletion.
- **Transitions**:
- "Review" (valid inputs, ENS resolved) → **ConfirmTx**
- "Review" with an unresolvable ENS name or an invalid amount → flash
message, no screen change
message, no screen change. An amount is valid when it is a number above
zero, written as digits with at most one decimal point, with nothing but
zeros after the token's last decimal place. ETH has 18 places, and a token
with more, or with an unknown number, is held to 18. Zeros past those
places leave the amount unchanged, so `1.0000000` of a 6-place token
passes and is sent as exactly 1. ConfirmTx makes the same check before
anything can be sent
- "Back" → previous screen (Home, AddressDetail, or AddressToken)
#### ConfirmTx (`confirm-tx`)
@@ -1647,8 +1754,10 @@ view would leave a wallet one click from deletion.
reserved either way
- Warnings: inline warnings from the local checks (scam address, self-send)
plus four reserved warning boxes made visible by the async checks —
recipient with no transaction history, recipient is a contract, burn
address, and an Etherscan phishing/scam label
recipient that has never sent a transaction and holds no ETH, recipient is
a contract, burn address, and an Etherscan phishing/scam label. An address
that has received ETH but never sent a transaction still holds that ETH,
so it gets no warning
- Errors (insufficient balance), plus three reserved error boxes — the
amount plus the fee exceeds the balance (ETH transfers), not enough ETH to
pay the fee for the transfer (ERC-20 transfers), and the fee could not be
@@ -1694,7 +1803,11 @@ view would leave a wallet one click from deletion.
- **When**: Transaction has been broadcast, waiting for on-chain confirmation.
- **Elements**:
- "Transaction Broadcast" heading (no back button — tx is irreversible)
- Amount + symbol
- Amount + symbol. For a swap the approval screen showed no `Amount` for,
such as one whose V4 swap action could not be read or a V4 exact-out swap,
`Unknown (could not be decoded)` with no symbol, not the transaction's ETH
value. When that swap's `Token In` is `ETH (native)`, the transaction's
ETH value in the network's currency, as for any other transaction
- To: color dot + full address + etherscan link; for a contract creation,
which has no recipient, "This transaction creates a new contract. It has
no recipient." instead
@@ -1741,7 +1854,9 @@ view would leave a wallet one click from deletion.
- **When**: Transaction broadcast failed, or timed out waiting for confirmation.
- **Elements**:
- "Transaction Failed" heading
- Amount + symbol
- Amount + symbol, or for a swap the approval screen showed no `Amount` for,
the same as on WaitTx: `Unknown (could not be decoded)`, unless its
`Token In` is `ETH (native)`
- To: color dot + full address + etherscan link, or for a contract creation
the same sentence as on WaitTx
- Error message (dashed border box)
@@ -1854,7 +1969,7 @@ view would leave a wallet one click from deletion.
under any address, with remove buttons
- Connected Sites: the origins of the sites allowed without "Remember my
choice" that are still connected, with remove buttons. Only the background
holds these, in memory, and Settings asks it for them with
holds these, in session storage, and Settings asks it for them with
`AUTISTMASK_GET_CONNECTED_SITES`
- Denied Sites: the origins remembered as denied, under any address, with
remove buttons
@@ -1873,9 +1988,10 @@ view would leave a wallet one click from deletion.
its origin is dropped from Allowed Sites under every address, and
`AUTISTMASK_REMOVE_SITE` has the background end every connection approved
without "Remember" from that origin, under any address, and send
`accountsChanged` with an empty list to the open tabs of that origin. The
same host under another scheme or port is another site and is left alone.
Only the extension's own pages may send either message
`accountsChanged` with an empty list to the open tabs of that origin.
Settings waits for the background's answer before it draws the lists
again. The same host under another scheme or port is another site and is
left alone. Only the extension's own pages may send either message
- `[x]` on a denied site → forgets the refusal, in place; it connects
nothing and tells the background nothing
- Ten clicks on the version → reveals the Debug well (no screen change)
@@ -1936,8 +2052,8 @@ view would leave a wallet one click from deletion.
(`src/shared/walletDelete.js`)
- Either way, every address the wallet held loses its site permissions of
both kinds: the remembered ones in storage, and the connections approved
without "Remember", which only the background holds, in memory, and drops
on `AUTISTMASK_ADDRESSES_REMOVED`
without "Remember", which only the background holds, in session storage,
and drops on `AUTISTMASK_ADDRESSES_REMOVED`
- "Confirm Delete" (wrong password) → "That password is incorrect. Please
try again." on the error line, nothing deleted
- "I have lost my password" → **DeleteWalletLostPassword**
@@ -2050,8 +2166,8 @@ view would leave a wallet one click from deletion.
one in this wallet follows the splice.
- The address loses its site permissions of both kinds, whether or not it was
the active one: the remembered ones in storage, and any connection approved
without "Remember", which only the background holds, in memory, and drops on
`AUTISTMASK_ADDRESSES_REMOVED`.
without "Remember", which only the background holds, in session storage, and
drops on `AUTISTMASK_ADDRESSES_REMOVED`.
- The wallet's derivation counter (`nextIndex`) is not rewound, so "+" derives a
fresh address rather than handing back the one just removed.
@@ -2080,14 +2196,22 @@ view would leave a wallet one click from deletion.
`wallet_requestPermissions` and is on neither the allowed nor the denied list.
A site is its full origin, `scheme://host[:port]`, on both lists and for a
connection allowed without "Remember": a choice for `https://dapp.example`
says nothing about `http://dapp.example` or another port of that host. The
background script prefers the toolbar popup (`action.openPopup()`) and falls
back to a separate popup window (`src/background/index.js`,
`requestApproval()`). Only one exists per site at a time: a further connection
request from a site whose prompt is still unanswered is refused with EIP-1193
code `-32002` and opens no new prompt. If that prompt was in a toolbar popup
that closed before it connected, and the toolbar popup has since been set to
open something else, the refused request shows that prompt again.
says nothing about `http://dapp.example` or another port of that host. A
request whose origin has no host names no site, so it is refused with EIP-1193
code `4100` and opens no prompt. Two such origins are `null`, which the
browser reports for a page with an opaque origin, such as one its server
sandboxes with a Content-Security-Policy, and `file://`, which Chrome reports
for a local file. Nor is such a page told the active address by
`accountsChanged`, even at a connected site's URL: the background names the
origin of the tab's URL on the event, and the content script passes the
address on only to a page whose own origin is that one. The background script
prefers the toolbar popup (`action.openPopup()`) and falls back to a separate
popup window (`src/background/index.js`, `requestApproval()`). Only one exists
per site at a time: a further connection request from a site whose prompt is
still unanswered is refused with EIP-1193 code `-32002` and opens no new
prompt. If that prompt was in a toolbar popup that closed before it connected,
and the toolbar popup has since been set to open something else, the refused
request shows that prompt again.
- **Elements**:
- "Connection Request" heading
- Phishing warning banner (shown when the hostname is on the phishing
@@ -2119,8 +2243,10 @@ view would leave a wallet one click from deletion.
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
is refused with EIP-1474 code `-32602`. A request that cannot be populated —
unreachable node, reverting gas estimate — opens no window and is failed back
to the site, with the node's own error code and data when the node refused it,
so the site can decode a revert, and with `-32603` otherwise. 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
EIP-1193 code `-32002` rather than being populated at the same nonce. It opens
@@ -2221,6 +2347,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 message leaves out a field its type declares, at any
level of nesting, or gives a `bool` field a value other than `true` or
`false` → shown with the error line naming the field and "Sign" disabled;
only "Reject" remains. ethers signs a `bool` by whether its value is
truthy, so the string `"false"` would be signed as true and a field left
out as false. The background refuses such a signature too and answers the
site with the same sentence
- 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
@@ -2747,7 +2880,11 @@ Currently supported:
or recipient, and amount.
- **Uniswap Universal Router**: `execute()` calls — shows swap direction (e.g.
"Swap USDT → ETH"), token addresses, amounts, execution steps, and deadline.
Decodes Permit2, V2/V3/V4 swaps, wrap/unwrap, and balance checks.
Decodes Permit2, V2/V3/V4 swaps, wrap/unwrap, and balance checks. A V4 swap
action is read only in the parameter shape deployed on mainnet and Sepolia,
not in the newer one on `v4-periphery` `main`, which adds a `minHopPriceX36`
field. An action in any other shape is not read, and its step is listed as
`V4 Swap (could not be decoded)`.
### Non-Goals Forever
+2 -1
View File
@@ -27,7 +27,8 @@
"web_accessible_resources": ["src/content/inpage.js"],
"browser_specific_settings": {
"gecko": {
"id": "autistmask@sneak.berlin"
"id": "autistmask@sneak.berlin",
"strict_min_version": "115.0"
}
}
}
+41 -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"
# Each docker run below uses this container name: docker run --rm returns
# only once its container is removed, so no run meets the one before it.
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,10 +104,24 @@ 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
# Approvals left open for seven minutes in minimized windows, past the
# point where Chrome stopped the background worker when nothing kept it
# running. Its own program, without Playwright: Playwright keeps DevTools
# attached to the worker, and Chrome never stops a worker DevTools is
# attached to. It reaches nothing but its own loopback server.
echo "Running the approvals left open for seven minutes..."
docker run --rm \
--name "$CONTAINER" \
--ipc=host \
-e HOME=/tmp \
"$IMAGE" \
node tests/e2e/slowApproval.js
}
main "$@"
+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
}
+197 -72
View File
@@ -22,8 +22,11 @@ const { debugFetch, log } = require("../shared/log");
const {
verifySignedTx,
verifySignature,
signedPrimaryType,
typedDataMessageRefusal,
typedDataChainRefusal,
failureIsRetryable,
nodeError,
describeTxFailure,
sameAddress,
ApprovalMismatchError,
@@ -44,7 +47,11 @@ const {
const {
actionApi,
getPlatformInfo,
runtimeApi,
sessionStorageGet,
sessionStorageRemove,
sessionStorageSet,
tabsQuery,
tabsSendMessage,
windowsApi,
@@ -57,16 +64,62 @@ const runtime = runtimeApi();
const windowsNs = windowsApi();
const actionNs = actionApi();
// Connected sites (in-memory, non-persisted): { "origin:address": true }
// Connections the user allowed without "Remember my choice for this site".
// Each is one item in the browser's session storage, key
// origin + ":" + address and value true, and session storage holds nothing
// else. Session storage outlives this worker being stopped when idle and
// started again, so such a connection lasts until it is ended below or the
// browser closes. One item per connection means that making one is a single
// write, and two never overwrite each other.
//
// A site is its full origin (scheme://host[:port]), here and in the
// remembered allowedSites/deniedSites lists alike: a grant to
// https://dapp.example says nothing about http://dapp.example or another port.
const connectedSites = {};
// An origin can carry a port, so the address is what follows the key's last
// colon and the origin is what precedes it.
async function isConnected(origin, address) {
const key = origin + ":" + address;
const items = await sessionStorageGet(key);
return items[key] === true;
}
// End every connection allowed without "Remember" whose key `ends` returns
// true for.
async function endConnections(ends) {
const keys = Object.keys(await sessionStorageGet(null)).filter(ends);
if (keys.length > 0) await sessionStorageRemove(keys);
}
// Pending approval requests: { id: { origin, resolve } }
const pendingApprovals = {};
// An approval, and the site's request waiting on it, exist only in this
// worker's memory. Left alone, Chrome stopped the worker about six minutes
// after the site's request while the user was still deciding, and the approval
// was lost with it. Chrome counts an extension call as activity, so while any
// approval is pending this worker makes one every 20 seconds, inside Chrome's
// 30-second idle limit. It is the worker's own timer, so it runs whether or not
// the approval window is visible, and it stops itself once nothing is pending.
let keepRunningTimer = null;
// Add an approval waiting for the user, and start the timer above if it is not
// running. This is the ONLY place an approval is added, as settleApproval() is
// the only place one is removed, so every kind of approval keeps the worker
// running while it waits.
function addPendingApproval(approval) {
pendingApprovals[approval.id] = approval;
if (keepRunningTimer) return;
keepRunningTimer = setInterval(() => {
if (Object.keys(pendingApprovals).length === 0) {
clearInterval(keepRunningTimer);
keepRunningTimer = null;
return;
}
// Only the call matters; its answer is not used.
getPlatformInfo().catch(() => {});
}, 20000);
}
// One transaction approval at a time, wallet-wide.
//
// The transaction a site asks for is populated before its approval window
@@ -242,6 +295,21 @@ function failureError(err) {
return { code: INTERNAL_ERROR_CODE, message: INTERNAL_ERROR_MESSAGE };
}
// The EIP-1193 error for a request that failed, with `message` as the
// sentence the page is shown. `rpcError` holds the JSON-RPC code and data the
// failure carries, if any: the node's own when the node refused the request,
// passed on unchanged so a site can tell a revert from any other failure and
// decode its revert data. A failure with no numeric code is the wallet's own,
// and is answered with the internal error code above.
function requestError(message, rpcError) {
if (rpcError && typeof rpcError.code === "number") {
const error = { code: rpcError.code, message };
if (rpcError.data !== undefined) error.data = rpcError.data;
return error;
}
return { code: INTERNAL_ERROR_CODE, message };
}
// The active address of a profile snapshot. Pure, and taking the snapshot as
// an argument rather than reading storage itself: a handler that has already
// read state must not answer "which account is this" from a SECOND, later read
@@ -287,7 +355,8 @@ function extractHostname(origin) {
}
}
// Proxy an RPC call to the Ethereum node
// Proxy an RPC call to the Ethereum node. An error the node answers with is
// thrown with the node's own JSON-RPC `code` and `data` on it, for the page.
async function proxyRpc(method, params) {
const rpcUrl = await getRpcUrl();
const resp = await debugFetch(rpcUrl, {
@@ -302,7 +371,10 @@ async function proxyRpc(method, params) {
});
const json = await resp.json();
if (json.error) {
throw new Error(json.error.message || "RPC error");
const err = new Error(json.error.message || "RPC error");
err.code = json.error.code;
err.data = json.error.data;
throw err;
}
return json.result;
}
@@ -423,7 +495,10 @@ function releaseApproval(approval) {
// the result on the floor, so a rejection here would be unhandled.
async function openApprovalWindow(id) {
const popupUrl = runtime.getURL("src/popup/index.html?approval=" + id);
const popupWidth = 360;
// As wide as the page: body in src/popup/styles/main.css. The browser
// counts the window's frame in this width; the page itself adds back
// whatever the frame takes (src/popup/index.js).
const popupWidth = 396;
const popupHeight = 600;
let currentWin = null;
@@ -443,7 +518,7 @@ async function openApprovalWindow(id) {
// Centred on a browser window only. The last focused window can be
// another approval window still open, and centring on one can give a
// position the browser refuses ("Bounds must be at least 50% within
// visible screen space"): headless Chrome reports this 360x600 popup as
// visible screen space"): headless Chrome reports this 396x600 popup as
// 1280x720. The request then failed with no window at all. Over a popup,
// the browser picks the position.
if (currentWin && currentWin.type === "normal") {
@@ -514,7 +589,7 @@ async function openApprovalWindow(id) {
function requestApproval(origin) {
return new Promise((resolve) => {
const id = crypto.randomUUID();
pendingApprovals[id] = { id, origin, resolve, type: "site" };
addPendingApproval({ id, origin, resolve, type: "site" });
if (actionNs && typeof actionNs.openPopup === "function") {
showInToolbarPopup(id);
@@ -557,14 +632,14 @@ function showInToolbarPopup(id) {
function requestTxApproval(origin, approvedTx, approvedFrom, slot) {
return new Promise((resolve) => {
const id = crypto.randomUUID();
pendingApprovals[id] = {
addPendingApproval({
id,
origin,
approvedTx,
approvedFrom,
resolve,
type: "tx",
};
});
if (slot) slot.approvalId = id;
openApprovalWindow(id);
@@ -578,14 +653,14 @@ function requestTxApproval(origin, approvedTx, approvedFrom, slot) {
function requestSignApproval(origin, signParams, approvedFrom) {
return new Promise((resolve) => {
const id = crypto.randomUUID();
pendingApprovals[id] = {
addPendingApproval({
id,
origin,
signParams,
approvedFrom,
resolve,
type: "sign",
};
});
openApprovalWindow(id);
});
@@ -598,14 +673,14 @@ function requestSignApproval(origin, signParams, approvedFrom) {
function requestNetworkApproval(origin, currentNetworkId, requestedNetworkId) {
return new Promise((resolve) => {
const id = crypto.randomUUID();
pendingApprovals[id] = {
addPendingApproval({
id,
origin,
currentNetworkId,
requestedNetworkId,
resolve,
type: "network",
};
});
openApprovalWindow(id);
});
@@ -695,7 +770,7 @@ async function handleConnectionRequest(origin) {
const s = await getState();
const activeAddress = activeAddressOf(s);
if (!activeAddress) {
return { error: { message: "No accounts available" } };
return { error: { code: 4100, message: "No accounts available" } };
}
const allowed = s.allowedSites[activeAddress] || [];
@@ -711,10 +786,10 @@ async function handleConnectionRequest(origin) {
};
}
// Check allowed list or in-memory connected
// Check allowed list or a connection allowed without "Remember"
if (
allowed.includes(origin) ||
connectedSites[origin + ":" + activeAddress]
(await isConnected(origin, activeAddress))
) {
return { result: [activeAddress] };
}
@@ -751,7 +826,7 @@ async function handleConnectionRequest(origin) {
if (decision.remember) {
await rememberSiteChoice("allowedSites", activeAddress, origin);
} else {
connectedSites[origin + ":" + activeAddress] = true;
await sessionStorageSet({ [origin + ":" + activeAddress]: true });
}
return { result: [activeAddress] };
} else {
@@ -805,7 +880,7 @@ async function handleRpc(method, params, origin) {
const allowed = s.allowedSites[activeAddress] || [];
if (
allowed.includes(origin) ||
connectedSites[origin + ":" + activeAddress]
(await isConnected(origin, activeAddress))
) {
return { result: [activeAddress] };
}
@@ -841,7 +916,7 @@ async function handleRpc(method, params, origin) {
const allowed = s.allowedSites[activeAddress] || [];
if (
!allowed.includes(origin) &&
!connectedSites[origin + ":" + activeAddress]
!(await isConnected(origin, activeAddress))
) {
return { error: { code: 4100, message: "Unauthorized" } };
}
@@ -934,10 +1009,10 @@ async function handleRpc(method, params, origin) {
const s = await getState();
const activeAddress = activeAddressOf(s);
const allowed = s.allowedSites[activeAddress] || [];
const isConnected =
const hasPermission =
allowed.includes(origin) ||
connectedSites[origin + ":" + activeAddress];
if (!isConnected || !activeAddress) {
(await isConnected(origin, activeAddress));
if (!hasPermission || !activeAddress) {
return { result: [] };
}
return {
@@ -959,12 +1034,12 @@ async function handleRpc(method, params, origin) {
const s = await getState();
const activeAddress = activeAddressOf(s);
if (!activeAddress)
return { error: { message: "No accounts available" } };
return { error: { code: 4100, message: "No accounts available" } };
const allowed = s.allowedSites[activeAddress] || [];
if (
!allowed.includes(origin) &&
!connectedSites[origin + ":" + activeAddress]
!(await isConnected(origin, activeAddress))
) {
return { error: { code: 4100, message: "Unauthorized" } };
}
@@ -1013,12 +1088,12 @@ async function handleRpc(method, params, origin) {
const s = await getState();
const activeAddress = activeAddressOf(s);
if (!activeAddress)
return { error: { message: "No accounts available" } };
return { error: { code: 4100, message: "No accounts available" } };
const allowed = s.allowedSites[activeAddress] || [];
if (
!allowed.includes(origin) &&
!connectedSites[origin + ":" + activeAddress]
!(await isConnected(origin, activeAddress))
) {
return { error: { code: 4100, message: "Unauthorized" } };
}
@@ -1060,12 +1135,13 @@ async function handleRpc(method, params, origin) {
const result = await proxyRpc(method, params);
return { result };
} catch (e) {
// A node that answered with an error is reported as itself. The
// wallet being unable to read its own profile is not that, and it
// must not be flattened into a message with no code: it goes back
// to the dispatcher, which has the one answer for it.
// A node that answered with an error is reported as itself, with
// its own code and data. The wallet being unable to read its own
// profile is not that, and it must not be answered as an ordinary
// failure: it goes back to the dispatcher, which has the one
// answer for it.
if (e instanceof StateUnusableError) throw e;
return { error: { message: e.message } };
return { error: requestError(e.message, e) };
}
}
@@ -1081,12 +1157,14 @@ async function handleRpc(method, params, origin) {
async function handleSendTransaction(params, origin) {
const s = await getState();
const activeAddress = activeAddressOf(s);
if (!activeAddress) return { error: { message: "No accounts available" } };
if (!activeAddress) {
return { error: { code: 4100, message: "No accounts available" } };
}
const allowed = s.allowedSites[activeAddress] || [];
if (
!allowed.includes(origin) &&
!connectedSites[origin + ":" + activeAddress]
!(await isConnected(origin, activeAddress))
) {
return { error: { code: 4100, message: "Unauthorized" } };
}
@@ -1140,7 +1218,7 @@ async function handleSendTransaction(params, origin) {
txParams,
);
} catch (e) {
return { error: { message: e.message } };
return { error: requestError(e.message, e) };
}
// Population is a network round trip, and the user can switch address
@@ -1150,10 +1228,9 @@ async function handleSendTransaction(params, origin) {
// while the page is still waiting and nothing has been displayed.
if (!sameAddress(await getActiveAddress(), activeAddress)) {
return {
error: {
message:
error: requestError(
"The active address changed while this transaction was being prepared, so it was not sent.",
},
),
};
}
@@ -1199,10 +1276,6 @@ async function broadcastChainChanged(chainId) {
// Broadcast accountsChanged to all tabs, respecting per-address permissions
async function broadcastAccountsChanged() {
// Clear non-remembered approvals on address switch
for (const key of Object.keys(connectedSites)) {
delete connectedSites[key];
}
// Reject and close any pending approval popups so they don't hang. An
// approval an attempt has already claimed is left alone entirely: it is
// being signed and broadcast right now, and neither rejecting it to the
@@ -1221,6 +1294,10 @@ async function broadcastAccountsChanged() {
}
}
resetPopupUrl();
// An address switch ends every connection allowed without "Remember". This
// waits on storage, so it comes after the pending approvals above, which
// are still rejected the moment the switch arrives.
await endConnections(() => true);
const s = await getState();
const activeAddress = activeAddressOf(s);
const allowed = activeAddress ? s.allowedSites[activeAddress] || [] : [];
@@ -1231,17 +1308,24 @@ async function broadcastAccountsChanged() {
return;
}
for (const tab of tabs) {
// The tab's URL is all the background knows of the page in it, and a
// page at a site's URL may have another origin: a page its server
// sandboxes with a Content-Security-Policy has the origin "null". So
// the event names the origin it was judged by, and the content script
// passes the address on only to a page whose own origin is that one
// (src/content/index.js).
const origin = tab.url ? new URL(tab.url).origin : "";
const hasPermission =
activeAddress &&
(allowed.includes(origin) ||
connectedSites[origin + ":" + activeAddress]);
(await isConnected(origin, activeAddress)));
// Same as chainChanged above: a tab without our content script
// rejects, and that is expected rather than a fault.
tabsSendMessage(tab.id, {
type: "AUTISTMASK_EVENT",
eventName: "accountsChanged",
data: hasPermission ? [activeAddress] : [],
origin,
}).catch(() => {});
}
}
@@ -1427,7 +1511,14 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
// the message carries. Firefox before 126 gives no sender.origin, so
// the origin of sender.url is used: the frame that sent the message,
// not the tab's page, which may be another site embedding that
// frame. With neither, the request is refused.
// frame.
//
// A site is scheme://host[:port], so a request is refused when its
// origin has no host, as when it has no origin at all, rather than
// credited to the site its URL names. The browser reports "null" for
// a page with an opaque origin, such as one its server sandboxes with
// a Content-Security-Policy, and Chrome reports "file://" for a local
// file.
let trustedOrigin = sender.origin;
if (!trustedOrigin && sender.url) {
try {
@@ -1436,7 +1527,13 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
// an unparseable URL leaves the origin unknown
}
}
if (!trustedOrigin) {
let host = "";
try {
host = new URL(trustedOrigin).host;
} catch {
// no origin, or "null", is not a URL and so has no host
}
if (!host) {
sendResponse({
error: {
code: 4100,
@@ -1633,7 +1730,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (outcome.spendApproval) {
settleApproval(
msg.id,
{ error: { message: outcome.error } },
{ error: requestError(outcome.error) },
{ holdsClaim: true },
);
} else {
@@ -1660,7 +1757,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
const outcome = describeTxFailure(TX_STAGE_NONCE, null);
settleApproval(
msg.id,
{ error: { message: outcome.error } },
{ error: requestError(outcome.error) },
{ holdsClaim: true },
);
sendResponse({
@@ -1693,10 +1790,13 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
// that says plainly it did not take the transaction:
// describeTxFailure() reclassifies that, and the stage it
// returns is the one reported.
//
// A node that refused the transaction gives the page its own
// code and data, whatever sentence the page is shown.
const outcome = describeTxFailure(TX_STAGE_BROADCAST, e);
settleApproval(
msg.id,
{ error: { message: outcome.error } },
{ error: requestError(outcome.error, nodeError(e)) },
{ holdsClaim: true },
);
sendResponse({
@@ -1787,16 +1887,25 @@ 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.
// The sign screen refuses typed data whose message ethers
// would sign differently from how it is shown, or 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,
const data = JSON.parse(sp.typedData);
const refusal =
typedDataMessageRefusal(
data.types,
signedPrimaryType(data.types),
data.message,
"",
) ||
typedDataChainRefusal(
data.domain,
networkById(s.networkId),
);
if (refusal) throw new ApprovalMismatchError(refusal);
@@ -1809,7 +1918,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (!retryable) {
settleApproval(
msg.id,
{ error: { message: errMsg } },
{ error: requestError(errMsg) },
{ holdsClaim: true },
);
} else {
@@ -1837,41 +1946,57 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
}
// The popup removed these addresses, so no site stays connected to them.
// A connectedSites key is origin + ":" + address, and an origin can carry
// a port, so the address is what follows the last colon.
if (msg.type === "AUTISTMASK_ADDRESSES_REMOVED") {
const removed = Array.isArray(msg.addresses) ? msg.addresses : [];
for (const key of Object.keys(connectedSites)) {
endConnections((key) => {
const address = key.slice(key.lastIndexOf(":") + 1);
if (removed.some((a) => sameAddress(a, address))) {
delete connectedSites[key];
}
}
return removed.some((a) => sameAddress(a, address));
}).catch((err) => {
log.errorf(
"ending the removed addresses' connections failed:",
err,
);
});
return false;
}
// Settings lists the sites connected without "Remember", which only this
// worker knows. The origin is what precedes the key's last colon.
// Settings lists the sites connected without "Remember", which only
// session storage holds.
if (msg.type === "AUTISTMASK_GET_CONNECTED_SITES") {
sendResponse(
Object.keys(connectedSites).map((key) =>
sessionStorageGet(null)
.then((items) =>
Object.keys(items).map((key) =>
key.slice(0, key.lastIndexOf(":")),
),
);
return false;
)
.catch((err) => {
log.errorf("listing the connected sites failed:", err);
return [];
})
.then(sendResponse);
return true;
}
// Settings removed this site (msg.origin) and has already dropped its
// remembered entries. Its connections approved without "Remember" end
// here, under every address, and its open tabs are told it has no account.
// The answer goes back once they have ended, and Settings waits for it
// before it lists the connected sites again.
if (msg.type === "AUTISTMASK_REMOVE_SITE") {
for (const key of Object.keys(connectedSites)) {
if (key.slice(0, key.lastIndexOf(":")) === msg.origin) {
delete connectedSites[key];
}
}
endConnections(
(key) => key.slice(0, key.lastIndexOf(":")) === msg.origin,
)
.catch((err) => {
log.errorf(
"ending the removed site's connections failed:",
err,
);
})
.then(() => {
broadcastSiteRemoved(msg.origin);
return false;
sendResponse(null);
});
return true;
}
// Settings switched the network and has saved it. Open tabs are told the
+12 -3
View File
@@ -49,14 +49,23 @@ window.addEventListener("message", (event) => {
// Listen for events pushed from the background (e.g. accountsChanged)
runtimeApi().onMessage.addListener((msg) => {
if (msg.type === "AUTISTMASK_EVENT") {
if (msg.type !== "AUTISTMASK_EVENT") return;
// The background chooses the addresses in accountsChanged for the site
// at the origin the event names, which is the origin of the tab's URL.
// This page hears them only when its own origin is that one. A page whose
// origin has no host is not a site: one its server sandboxes with a
// Content-Security-Policy has the origin "null" whatever its URL, and is
// told no address, as its requests are refused.
let data = msg.data;
if (msg.eventName === "accountsChanged" && msg.origin !== window.origin) {
data = [];
}
window.postMessage(
{
type: "AUTISTMASK_EVENT",
eventName: msg.eventName,
data: msg.data,
data,
},
"*",
);
}
});
+5 -10
View File
@@ -634,17 +634,12 @@
</div>
</div>
<div id="confirm-warnings" class="mb-2 invisible"></div>
<div id="confirm-recipient-warning" class="mb-2 invisible">
<!-- These two sentences name the network's native token, so
show() in confirmTx.js sets them. -->
<div
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
>
WARNING: The recipient address has ZERO transaction
history. This may indicate a fresh or unused address.
Double-check the address before sending.
</div>
</div>
<!-- Its sentence names the network's native token, so show()
in confirmTx.js sets it. -->
id="confirm-recipient-warning"
class="mb-2 border border-red-500 border-dashed p-2 text-xs font-bold text-red-500 invisible"
></div>
<div
id="confirm-contract-warning"
class="mb-2 border border-red-500 border-dashed p-2 text-xs font-bold text-red-500 invisible"
+59 -6
View File
@@ -21,6 +21,12 @@ const {
goBack,
} = require("./views/helpers");
const { applyTheme } = require("./theme");
const {
tabsGetCurrent,
tabsGetZoom,
windowsGetCurrent,
windowsUpdate,
} = require("../shared/browserApi");
// Renders a view the popup lands on without having navigated to it forward:
// on restore here, and on Back. Only the views that can be fully re-rendered
// from persisted state (RESTORABLE_VIEWS, src/shared/restorableViews.js) go
@@ -58,8 +64,8 @@ window.addEventListener("pagehide", () => pageClosed.abort());
let refreshInFlight = false;
// The ten-second refresh init() starts, stopped when the popup moves to the
// recovery screen: there is no profile left to refresh.
// The ten-second refresh startRefresh() starts, stopped when the popup moves to
// the recovery screen: there is no profile left to refresh.
let refreshTimer = null;
async function doRefreshAndRender() {
@@ -93,9 +99,23 @@ async function doRefreshAndRender() {
}
}
// Refreshes now and every ten seconds from then on. Called once a wallet
// exists: by init() when the popup opens with one, and by AddWallet when it
// adds one, which in a popup that opened on Welcome is the first
// (https://git.eeqj.de/sneak/AutistMask/issues/530). With the refresh already
// running it starts no second one, and on the recovery screen it does nothing.
function startRefresh() {
if (!$("view-state-recovery").classList.contains("hidden")) return;
doRefreshAndRender();
if (refreshTimer === null) {
refreshTimer = setInterval(doRefreshAndRender, 10000);
}
}
const ctx = {
renderWalletList,
doRefreshAndRender,
startRefresh,
pageClosed: pageClosed.signal,
showAddWalletView: () => {
pushCurrentView();
@@ -163,6 +183,40 @@ function fallbackView() {
showView("main");
}
// The background opens the approval window as wide as the page, but the
// browser counts the window's frame in that width, and a screen taller than
// the window takes a vertical scrollbar out of it too. So once the screen is
// up, the window is widened by however far the page would scroll sideways.
// That distance is in page pixels and the window's width in screen pixels:
// at 150% zoom one page pixel is 1.5 screen pixels. The zoom is read for this
// page's own tab, because asked without a tab id Firefox answers for the
// window focused last. Firefox draws some zoom steps a little larger than the
// zoom it reports, 170% among them, so once the page has its new width (its
// resize event) it is measured again, and the window widened again while the
// page still falls short, three times at most. A window that cannot grow sends
// no resize event, and the page stops there. The connection prompt can also
// show in the toolbar popup, whose window is the user's browser window; that
// one is left alone.
async function widenApprovalWindow() {
const page = document.documentElement;
if (page.scrollWidth <= page.clientWidth) return;
const win = await windowsGetCurrent();
if (win.type !== "popup") return;
const tab = await tabsGetCurrent();
const zoom = await tabsGetZoom(tab.id);
let width = win.width;
for (let tries = 0; tries < 3; tries++) {
const sideways = page.scrollWidth - page.clientWidth;
if (sideways <= 0) return;
width += Math.ceil(sideways * zoom);
const resized = new Promise((resolve) =>
window.addEventListener("resize", resolve, { once: true }),
);
await windowsUpdate(win.id, { width });
await resized;
}
}
async function init() {
// First, before anything can save: showView() saves on every navigation
// without awaiting, so a save that fails from here on has somewhere to be
@@ -239,8 +293,8 @@ async function init() {
// 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. show() puts the approval's own screen up once the
// background has described it.
approval.show(approvalId);
// background has described it; the window is widened to fit it then.
approval.show(approvalId).then(widenApprovalWindow);
return;
}
@@ -278,8 +332,7 @@ async function init() {
} else {
renderWalletList();
restoreView();
doRefreshAndRender();
refreshTimer = setInterval(doRefreshAndRender, 10000);
startRefresh();
}
}
+28 -3
View File
@@ -20,6 +20,7 @@ const {
const { encryptWithPassword } = require("../../shared/vault");
const { state, saveState } = require("../../shared/state");
const { scanForAddresses } = require("../../shared/balances");
const { notify } = require("../../shared/browserApi");
/**
* Check if an address already exists in ANY wallet (hd, xprv, or key).
@@ -194,6 +195,9 @@ async function importMnemonic(ctx) {
};
state.wallets.push(wallet);
state.hasWallet = true;
if (state.activeAddress === null) {
state.activeAddress = firstAddress;
}
await saveState();
clearViewStack();
ctx.renderWalletList();
@@ -215,14 +219,22 @@ async function importMnemonic(ctx) {
tokenBalances: [],
}));
wallet.nextIndex = scan.nextIndex;
// The new list leaves out the first address when the scan found it
// unused. If it was the active one, the new list's first takes over,
// and the background is told as when the user picks one on Home.
const activeMoved =
state.activeAddress === firstAddress &&
wallet.addresses[0].address !== firstAddress;
if (activeMoved) state.activeAddress = wallet.addresses[0].address;
await saveState();
ctx.renderWalletList();
if (activeMoved) notify({ type: "AUTISTMASK_ACTIVE_CHANGED" });
showFlash("Found " + scan.addresses.length + " addresses.");
} else {
showFlash("Ready.", 1000);
}
ctx.doRefreshAndRender();
ctx.startRefresh();
}
async function importPrivateKey(ctx) {
@@ -254,12 +266,15 @@ async function importPrivateKey(ctx) {
addresses: [{ address: addr, balance: "0.0000", tokenBalances: [] }],
});
state.hasWallet = true;
if (state.activeAddress === null) {
state.activeAddress = addr;
}
await saveState();
clearViewStack();
ctx.renderWalletList();
showView("main");
ctx.doRefreshAndRender();
ctx.startRefresh();
}
async function importXprvKey(ctx) {
@@ -315,6 +330,9 @@ async function importXprvKey(ctx) {
};
state.wallets.push(wallet);
state.hasWallet = true;
if (state.activeAddress === null) {
state.activeAddress = firstAddress;
}
await saveState();
clearViewStack();
ctx.renderWalletList();
@@ -336,14 +354,21 @@ async function importXprvKey(ctx) {
tokenBalances: [],
}));
wallet.nextIndex = scan.nextIndex;
// As for a recovery phrase: an active first address the scan left
// out gives way to the new list's first.
const activeMoved =
state.activeAddress === firstAddress &&
wallet.addresses[0].address !== firstAddress;
if (activeMoved) state.activeAddress = wallet.addresses[0].address;
await saveState();
ctx.renderWalletList();
if (activeMoved) notify({ type: "AUTISTMASK_ACTIVE_CHANGED" });
showFlash("Found " + scan.addresses.length + " addresses.");
} else {
showFlash("Ready.", 1000);
}
ctx.doRefreshAndRender();
ctx.startRefresh();
}
function init(ctx) {
+26 -15
View File
@@ -27,7 +27,6 @@ const {
Interface,
MaxUint256,
toUtf8String,
TypedDataEncoder,
} = require("ethers");
const { getPrice, formatUsd } = require("../../shared/prices");
const { ERC20_ABI } = require("../../shared/constants");
@@ -48,6 +47,8 @@ const { getSignerForAddress } = require("../../shared/wallet");
const { walletDefect } = require("../../shared/walletDefects");
const {
describeSigningFailure,
signedPrimaryType,
typedDataMessageRefusal,
typedDataChainRefusal,
} = require("../../shared/approvalVerify");
const txStatus = require("./txStatus");
@@ -298,6 +299,23 @@ function showTxApproval(details) {
sources,
);
}
// A swap the decoder read no amount for, such as one whose V4 swap
// action could not be read or a V4 exact-out swap. The wait and error
// screens say the amount could not be decoded, with no currency after
// it, rather than show the transaction's ETH value as the amount. A
// swap paid in native ETH keeps that value: it is the ETH the swap
// pays (for an exact-out swap, the most it pays), not a figure that
// had to be read from the calldata.
const tokenIn = decoded.details.find((d) => d.label === "Token In");
if (
tokenIn &&
tokenIn.value !== "ETH (native)" &&
!decoded.details.some((d) => d.label === "Amount")
) {
pendingTxDetails.amount = "Unknown (could not be decoded)";
pendingTxDetails.token = null;
pendingTxDetails.tokenSymbol = null;
}
}
// Carry decoded calldata info through to success/error views
@@ -432,22 +450,12 @@ function markInvisibleCharacters(text) {
);
}
// The type ethers will sign typed data as. ethers does not read the page's
// `primaryType`: it takes the one struct in `types` that no other struct
// refers to. Throws when the types name no such single struct, which ethers
// would refuse to sign as well.
function signedPrimaryType(types) {
const structs = { ...types };
// ethers derives EIP712Domain itself and rejects it as an input.
delete structs.EIP712Domain;
return TypedDataEncoder.getPrimaryType(structs);
}
// 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. Its domain may name no chain but the active
// network's.
// message while another is signed. Its message has to hold every declared
// field, with each `bool` true or false, and 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;
@@ -470,7 +478,10 @@ function typedDataRefusal(sp) {
", so it cannot be signed."
);
}
return typedDataChainRefusal(data.domain, currentNetwork());
return (
typedDataMessageRefusal(data.types, signed, data.message, "") ||
typedDataChainRefusal(data.domain, currentNetwork())
);
}
// The largest amount a Permit2 allowance can hold, a uint160. Permit2 treats
+9 -1
View File
@@ -217,7 +217,14 @@ function show(txInfo) {
nativeCurrency() +
" to this address and try again.";
// Shown later, once checkRecipientHistory() finds a contract.
// Both shown later, by checkRecipientHistory(). The first sentence says
// exactly what getFullWarnings() checks.
$("confirm-recipient-warning").textContent =
"WARNING: The recipient address has never sent a transaction and" +
" holds no " +
nativeCurrency() +
". This may indicate a fresh or unused address. Double-check the" +
" address before sending.";
$("confirm-contract-warning").textContent =
"WARNING: The recipient is a smart contract. Sending " +
nativeCurrency() +
@@ -278,6 +285,7 @@ function renderValidation(txInfo) {
const { canSend, codes } = validateTransfer({
isErc20,
amount: txInfo.amount,
tokenDecimals: txInfo.tokenDecimals,
ethBalance: txInfo.balance,
tokenBalance: txInfo.tokenBalance,
feeStatus,
+30 -21
View File
@@ -24,6 +24,7 @@ const {
} = require("../../shared/amountDisplay");
const {
feeReserveWei,
isValidAmount,
maxEthAmount,
maxTokenAmount,
} = require("../../shared/txValidation");
@@ -255,13 +256,18 @@ async function fillMaxAmount() {
const token = state.selectedToken || $("send-token").value;
if (token !== "ETH") {
const bal = tokenBalanceAndDecimals(addr, token).tokenBalance;
const { tokenBalance: bal, tokenDecimals } = tokenBalanceAndDecimals(
addr,
token,
);
if (bal == null) {
showFlash("This token's balance is unknown.");
return;
}
const amount = maxTokenAmount(bal);
if (!(parseFloat(amount) > 0)) {
// The balance is stored at the token's own decimal places and cut to
// 18 above, so the only balance this refuses is zero.
if (!isValidAmount(amount, tokenDecimals)) {
showFlash("This token's balance is zero.");
return;
}
@@ -367,7 +373,28 @@ function init(_ctx) {
);
return;
}
if (!amount || isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) {
const token = state.selectedToken || $("send-token").value;
const addr = currentAddress();
let tokenSymbol = null;
let tokenBalance = null;
let tokenDecimals = null;
if (token !== "ETH") {
tokenSymbol = resolveSymbol(
token,
addr.tokenBalances,
state.trackedTokens,
);
({ tokenBalance, tokenDecimals } = tokenBalanceAndDecimals(
addr,
token,
));
}
// The confirmation screen's own amount check, so it is never shown
// an amount it would refuse.
if (!isValidAmount(amount, tokenDecimals)) {
showFlash("Please enter a valid amount.");
return;
}
@@ -391,24 +418,6 @@ function init(_ctx) {
}
}
const token = state.selectedToken || $("send-token").value;
const addr = currentAddress();
let tokenSymbol = null;
let tokenBalance = null;
let tokenDecimals = null;
if (token !== "ETH") {
tokenSymbol = resolveSymbol(
token,
addr.tokenBalances,
state.trackedTokens,
);
({ tokenBalance, tokenDecimals } = tokenBalanceAndDecimals(
addr,
token,
));
}
ctx.showConfirmTx({
from: addr.address,
to: resolvedTo,
+6 -4
View File
@@ -76,11 +76,13 @@ function forgetOrigin(siteMap, origin) {
// Removing a site from Allowed Sites or Connected Sites disconnects it: it is
// no longer allowed under any address, and the background ends its
// connections approved without "Remember" and tells its open tabs.
// connections approved without "Remember" and tells its open tabs. The
// background answers once those have ended, so the lists drawn after it no
// longer show the site.
async function removeAllowedSite(origin) {
forgetOrigin(state.allowedSites, origin);
await saveState();
notify({ type: "AUTISTMASK_REMOVE_SITE", origin });
await sendMessage({ type: "AUTISTMASK_REMOVE_SITE", origin });
await renderSiteLists();
}
@@ -230,8 +232,8 @@ async function renderSiteLists() {
Object.values(state.deniedSites).flat(),
removeDeniedSite,
);
// Sites allowed without "Remember" are held only by the background, in
// memory, so it is asked for them.
// Sites allowed without "Remember" are held only in session storage, which
// the background keeps, so it is asked for them.
renderSiteList(
"settings-connected-sites",
await sendMessage({ type: "AUTISTMASK_GET_CONNECTED_SITES" }),
+29 -16
View File
@@ -91,12 +91,17 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
// native currency of txInfo.chainId, the network the transaction was sent
// 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)
: displaySymbol(txInfo.tokenSymbol || "?");
$("wait-tx-summary").textContent = txInfo.amount + " " + symbol;
// is open or before a later popup resumes it. A null token is a swap whose
// amount was not decoded (see showTxApproval() in approval.js): the amount
// says so, and no symbol follows it.
let symbol = "";
if (txInfo.token === "ETH") {
symbol = nativeCurrencyByChainId(txInfo.chainId);
} else if (txInfo.token !== null) {
symbol = displaySymbol(txInfo.tokenSymbol || "?");
}
$("wait-tx-summary").textContent =
txInfo.amount + (symbol ? " " + symbol : "");
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to);
$("wait-tx-hash").innerHTML = txHashHtml(txHash);
attachCopyHandlers("view-wait-tx");
@@ -231,10 +236,13 @@ function restoreWait() {
function showSuccess(txInfo, txHash, blockNumber) {
endWait();
const symbol =
txInfo.token === "ETH"
? nativeCurrencyByChainId(txInfo.chainId)
: displaySymbol(txInfo.tokenSymbol || "?");
// As on the wait screen, a null token has no symbol.
let symbol = "";
if (txInfo.token === "ETH") {
symbol = nativeCurrencyByChainId(txInfo.chainId);
} else if (txInfo.token !== null) {
symbol = displaySymbol(txInfo.tokenSymbol || "?");
}
state.viewData = {
amount: txInfo.amount,
symbol: symbol,
@@ -294,7 +302,8 @@ function renderSuccess() {
} else {
summarySection.classList.remove("hidden");
toSection.classList.remove("hidden");
$("success-tx-summary").textContent = d.amount + " " + d.symbol;
$("success-tx-summary").textContent =
d.amount + (d.symbol ? " " + d.symbol : "");
$("success-tx-to").innerHTML = toAddressHtml(d.to);
}
@@ -317,10 +326,13 @@ function renderSuccess() {
function showError(txInfo, txHash, message) {
endWait();
const symbol =
txInfo.token === "ETH"
? nativeCurrencyByChainId(txInfo.chainId)
: displaySymbol(txInfo.tokenSymbol || "?");
// As on the wait screen, a null token has no symbol.
let symbol = "";
if (txInfo.token === "ETH") {
symbol = nativeCurrencyByChainId(txInfo.chainId);
} else if (txInfo.token !== null) {
symbol = displaySymbol(txInfo.tokenSymbol || "?");
}
state.viewData = {
amount: txInfo.amount,
symbol: symbol,
@@ -334,7 +346,8 @@ function showError(txInfo, txHash, message) {
function renderError() {
const d = state.viewData;
if (!d || !d.message) return;
$("error-tx-summary").textContent = d.amount + " " + d.symbol;
$("error-tx-summary").textContent =
d.amount + (d.symbol ? " " + d.symbol : "");
$("error-tx-to").innerHTML = toAddressHtml(d.to);
$("error-tx-message").textContent = d.message;
+15 -5
View File
@@ -51,7 +51,8 @@ function getLocalWarnings(address, options = {}) {
/**
* Check an address against local lists AND via RPC queries.
* Async — performs network calls to check contract status and tx history.
* Async — performs network calls to check contract status, tx count and
* balance.
*
* @param {string} address - The target address to check.
* @param {object} provider - An ethers.js provider instance.
@@ -78,21 +79,30 @@ async function getFullWarnings(address, provider, options = {}) {
log.errorf("contract check failed:", e.shortMessage || e.message);
}
// Skip tx count check for contracts — they may legitimately have
// zero inbound EOA transactions.
// The transaction count is the number of transactions the address has
// sent, so an address that has only ever received has a count of zero.
// Native token it received stays in its balance until it sends a
// transaction, so the warning also needs a zero balance. A contract is
// skipped: it has its own warning above.
if (!isContract) {
try {
const txCount = await provider.getTransactionCount(address);
if (txCount === 0) {
const balance = await provider.getBalance(address);
if (balance === 0n) {
warnings.push({
type: "new-address",
message:
"This address has never sent a transaction. Double-check it is correct.",
"This address has never sent a transaction and holds none of the network's native token. Double-check it is correct.",
severity: "info",
});
}
}
} catch (e) {
log.errorf("tx count check failed:", e.shortMessage || e.message);
log.errorf(
"tx count or balance check failed:",
e.shortMessage || e.message,
);
}
}
+23 -3
View File
@@ -41,6 +41,7 @@ const {
ALLOWED_TX_TYPES,
SERIALIZED_FIELDS,
assertWithinCeilings,
nodeError,
} = require("./approvalVerify");
// How long the population may take before the request is failed back to the
@@ -72,15 +73,27 @@ const REQUEST_FIELDS = [
"type",
];
// EIP-1474's code for invalid parameters: what the page is given when the
// site asked for a transaction this wallet refuses as it was asked for.
const INVALID_PARAMS_CODE = -32602;
// Why no approval can be raised. The message is the sentence the page is
// shown. `code` and `data` are the JSON-RPC ones it is given with it: the
// node's own, unchanged, when the node refused the transaction, so a site can
// decode a revert; INVALID_PARAMS_CODE when the site's request is refused;
// none when the failure is the wallet's own, which the background answers as
// an internal error.
class ApprovalPrepareError extends Error {
constructor(message) {
constructor(message, code, data) {
super(message);
this.name = "ApprovalPrepareError";
if (code !== undefined) this.code = code;
if (data !== undefined) this.data = data;
}
}
function fail(message) {
return new ApprovalPrepareError(message);
function fail(message, code, data) {
return new ApprovalPrepareError(message, code, data);
}
function present(v) {
@@ -134,6 +147,7 @@ function requestFrom(txParams, from) {
) {
throw fail(
"The site asked for two different gas limits for this transaction.",
INVALID_PARAMS_CODE,
);
}
request.gasLimit = txParams.gas;
@@ -144,6 +158,7 @@ function requestFrom(txParams, from) {
) {
throw fail(
"The site asked for a transaction of a type this wallet does not sign.",
INVALID_PARAMS_CODE,
);
}
return request;
@@ -228,6 +243,9 @@ async function prepareApprovalTx(provider, from, txParams) {
);
} catch (e) {
if (e instanceof ApprovalPrepareError) throw e;
// A refusal from the node, such as a reverting gas estimate, keeps
// the node's code and revert data.
const fromNode = nodeError(e);
throw fail(
sentence(
"The transaction could not be prepared: " +
@@ -235,6 +253,8 @@ async function prepareApprovalTx(provider, from, txParams) {
e.message ||
"the network did not answer"),
),
fromNode?.code,
fromNode?.data,
);
}
+71 -1
View File
@@ -56,6 +56,7 @@ const {
getBigInt,
getBytes,
toQuantity,
TypedDataEncoder,
verifyMessage,
verifyTypedData,
} = require("ethers");
@@ -636,6 +637,63 @@ function verifySignature(signParams, signature, expectedFrom) {
return recovered;
}
// The type ethers will sign typed data as. ethers does not read the page's
// `primaryType`: it takes the one struct in `types` that no other struct
// refers to. Throws when the types name no such single struct, which ethers
// would refuse to sign as well.
function signedPrimaryType(types) {
const structs = { ...types };
// ethers derives EIP712Domain itself and rejects it as an input.
delete structs.EIP712Domain;
return TypedDataEncoder.getPrimaryType(structs);
}
// Why a typed data message cannot be signed as the screen shows it, as a
// sentence, or null when it can. ethers signs a `bool` field by whether its
// value is truthy, so the string "false" is signed as true and a field left
// out as false. Each field that `type` declares, at every level of nesting,
// must be one of the value's own keys, and each `bool` must be true or false.
// Called with the type ethers signs, the message, and a `path` of "".
function typedDataMessageRefusal(types, type, value, path) {
const itemType = type.replace(/\[\d*\]$/, "");
if (itemType !== type) {
// ethers refuses to sign a list field that is not a list.
if (!Array.isArray(value)) return null;
for (let i = 0; i < value.length; i++) {
const refusal = typedDataMessageRefusal(
types,
itemType,
value[i],
`${path}[${i}]`,
);
if (refusal) return refusal;
}
return null;
}
if (type === "bool" && value !== true && value !== false) {
return `The field "${path}" is not true or false, so this typed data cannot be signed.`;
}
for (const field of types[type] || []) {
const at = path ? `${path}.${field.name}` : field.name;
// An own key only: a plain lookup of `constructor` finds a function.
const present =
value !== null &&
typeof value === "object" &&
Object.prototype.hasOwnProperty.call(value, field.name);
if (!present) {
return `The field "${at}" is missing from the message, so this typed data cannot be signed.`;
}
const refusal = typedDataMessageRefusal(
types,
field.type,
value[field.name],
at,
);
if (refusal) return refusal;
}
return null;
}
// 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.
@@ -688,6 +746,15 @@ function errorText(err) {
return "The transaction could not be sent.";
}
// The JSON-RPC error object the node answered with, `{ code, message, data }`,
// out of the error ethers threw for that answer. ethers keeps it under
// `info.error`, or under `error` when it could not classify it. Null when
// there is none, as for a node that could not be reached.
function nodeError(err) {
if (!err || typeof err !== "object") return null;
return err.error || (err.info && err.info.error) || null;
}
// Every string a failure might carry its reason in. ethers reports the node's
// own words in `shortMessage`, but a JSON-RPC error it could not classify is
// nested under `error` or `info.error` with the node's message intact, and the
@@ -699,7 +766,7 @@ function failureTexts(err) {
for (const text of [err.shortMessage, err.message, err.reason]) {
if (text) texts.push(String(text));
}
const nested = err.error || (err.info && err.info.error);
const nested = nodeError(err);
if (nested && nested.message) texts.push(String(nested.message));
return texts;
}
@@ -824,6 +891,8 @@ function describeSigningFailure(response, fallbackMessage) {
module.exports = {
verifySignedTx,
verifySignature,
signedPrimaryType,
typedDataMessageRefusal,
typedDataChainRefusal,
assertNoForbiddenFields,
assertNothingUnchecked,
@@ -831,6 +900,7 @@ module.exports = {
assertWithinCeilings,
sameAddress,
failureIsRetryable,
nodeError,
isNonceCollision,
describeTxFailure,
describeSigningFailure,
+101 -5
View File
@@ -145,6 +145,14 @@ function sendMessage(message) {
return invoke(runtimeApi(), "sendMessage", message);
}
/**
* @returns {Promise<Object>} the operating system and processor the browser
* runs on.
*/
function getPlatformInfo() {
return invoke(runtimeApi(), "getPlatformInfo");
}
/**
* Send a message nobody is expected to answer, and swallow the fact that
* nobody did.
@@ -166,9 +174,9 @@ function notify(message) {
// default: resolving {} would make an existing wallet read back as no wallet,
// and resolving a no-op write would discard the user's state with nothing
// logged. A caller that wants to degrade takes storageLocal() directly.
function storageUnavailable(method) {
function storageUnavailable(area, method) {
return Promise.reject(
new Error("extension storage.local is not available: " + method),
new Error("extension storage." + area + " is not available: " + method),
);
}
@@ -179,7 +187,7 @@ function storageUnavailable(method) {
*/
function storageGet(keys) {
const storage = storageLocal();
if (!storage) return storageUnavailable("get");
if (!storage) return storageUnavailable("local", "get");
return Promise.resolve(storage.get(keys));
}
@@ -190,7 +198,7 @@ function storageGet(keys) {
*/
function storageSet(items) {
const storage = storageLocal();
if (!storage) return storageUnavailable("set");
if (!storage) return storageUnavailable("local", "set");
return Promise.resolve(storage.set(items));
}
@@ -207,7 +215,55 @@ function storageSet(items) {
*/
function storageRemove(keys) {
const storage = storageLocal();
if (!storage) return storageUnavailable("remove");
if (!storage) return storageUnavailable("local", "remove");
return Promise.resolve(storage.remove(keys));
}
// `storage.session`, the browser's session storage: what is kept there lasts
// until the browser closes. It survives the background worker being stopped
// and started again, which module-level variables do not (README, Background
// scheduling). The background keeps the connections allowed without
// "Remember" there; see src/background/index.js. Firefox has it from version
// 115, the minimum manifest/firefox.json declares.
//
// The three below reject where it is absent, as the three above do where
// `storage.local` is: a write that silently did nothing would tell a site it
// is connected and then refuse its next request.
function storageSession() {
const storage = namespaceMember("storage");
return (storage && storage.session) || null;
}
/**
* @param {string|string[]|null} keys null for every item.
* @returns {Promise<Object>} the stored items.
* @throws rejects where `storage.session` is absent.
*/
function sessionStorageGet(keys) {
const storage = storageSession();
if (!storage) return storageUnavailable("session", "get");
return Promise.resolve(storage.get(keys));
}
/**
* @param {Object} items
* @returns {Promise<void>}
* @throws rejects where `storage.session` is absent.
*/
function sessionStorageSet(items) {
const storage = storageSession();
if (!storage) return storageUnavailable("session", "set");
return Promise.resolve(storage.set(items));
}
/**
* @param {string|string[]} keys
* @returns {Promise<void>}
* @throws rejects where `storage.session` is absent.
*/
function sessionStorageRemove(keys) {
const storage = storageSession();
if (!storage) return storageUnavailable("session", "remove");
return Promise.resolve(storage.remove(keys));
}
@@ -235,6 +291,22 @@ function tabsSendMessage(tabId, message) {
return invoke(tabsApi(), "sendMessage", tabId, message);
}
/**
* @returns {Promise<Object|undefined>} the tab the calling page is in, or
* undefined for a page not in a tab, such as the toolbar popup.
*/
function tabsGetCurrent() {
return invoke(tabsApi(), "getCurrent");
}
/**
* @param {number} tabId
* @returns {Promise<number>} that tab's zoom: 1.5 at 150%.
*/
function tabsGetZoom(tabId) {
return invoke(tabsApi(), "getZoom", tabId);
}
/**
* @param {Object} createData
* @returns {Promise<Object>} the created window.
@@ -250,6 +322,22 @@ function windowsGetLastFocused() {
return invoke(windowsApi(), "getLastFocused");
}
/**
* @returns {Promise<Object>} the window the calling page is in.
*/
function windowsGetCurrent() {
return invoke(windowsApi(), "getCurrent");
}
/**
* @param {number} windowId
* @param {Object} updateInfo
* @returns {Promise<Object>} the updated window.
*/
function windowsUpdate(windowId, updateInfo) {
return invoke(windowsApi(), "update", windowId, updateInfo);
}
/**
* @param {number} windowId
* @returns {Promise<void>}
@@ -262,19 +350,27 @@ module.exports = {
actionApi,
alarmsApi,
extensionApi,
getPlatformInfo,
hasBrowserNamespace,
notify,
runtimeApi,
sendMessage,
sessionStorageGet,
sessionStorageRemove,
sessionStorageSet,
storageGet,
storageLocal,
storageRemove,
storageSet,
tabsApi,
tabsGetCurrent,
tabsGetZoom,
tabsQuery,
tabsSendMessage,
windowsApi,
windowsCreate,
windowsGetCurrent,
windowsGetLastFocused,
windowsRemove,
windowsUpdate,
};
+37 -11
View File
@@ -1,4 +1,5 @@
// Balance arithmetic for the Send and transaction confirmation screens.
// The amount check and balance arithmetic for the Send and transaction
// confirmation screens.
//
// Pure: no DOM, no network, no state. Everything is exact integer math on
// 18-decimal fixed point (wei for ETH), so it can be unit tested directly
@@ -20,7 +21,7 @@ const FEE_KNOWN = "known";
const FEE_UNAVAILABLE = "unavailable";
const CODES = {
// The amount is not a non-negative number we can do exact arithmetic on.
// The amount is one isValidAmount() refuses.
AMOUNT_INVALID: "amount-invalid",
// ERC-20: the token amount exceeds the token balance.
INSUFFICIENT_TOKEN: "insufficient-token",
@@ -87,6 +88,27 @@ function toFixedPoint(value) {
}
}
// Whether `amount` can be sent: a number above zero, written as digits with
// at most one decimal point ("0.5", "12", ".5"), with nothing but zeros after
// the token's last decimal place, `tokenDecimals`, and nothing but zeros after
// the 18th, the places the arithmetic here works in, which are ETH's. ETH, and
// a token whose decimal places are unknown, pass null and are held to the 18
// (the confirmation screen refuses to send such a token anyway). Zeros past
// those places are accepted because parseUnits() drops them here and again
// when the transfer is encoded, so "1.0000000" of a 6-place token is exactly
// 1 everywhere. Refuses "1e-3", "1abc", "0" and "-1". The Send screen checks
// it before the confirmation screen opens, and validateTransfer() checks it
// again there.
function isValidAmount(amount, tokenDecimals = null) {
if (typeof amount !== "string") return false;
const places = Math.min(tokenDecimals ?? SCALE_DECIMALS, SCALE_DECIMALS);
try {
return parseUnits(amount.trim(), places) > 0n;
} catch {
return false;
}
}
// The most ETH a send can carry: the exact balance minus the fee reserve from
// feeReserveWei(), as a decimal string, so validateTransfer() passes it with
// exactly that reserve left behind. `ethBalance` is the exact decimal string
@@ -112,9 +134,11 @@ function maxTokenAmount(tokenBalance) {
// Validate a pending transfer against the balances that must cover it.
//
// isErc20 — token transfer rather than a native ETH transfer
// amount — human decimal string being sent, non-negative. Anything
// else, a negative value included, is an unusable amount
// amount — human decimal string being sent. One isValidAmount()
// refuses, a negative value included, is an unusable amount
// rather than an amount that passes every comparison.
// tokenDecimals — ERC-20: the token's decimal places, or null when they
// are unknown. Passed to isValidAmount().
// ethBalance — human decimal string, the sender's ETH balance
// tokenBalance — human decimal string, the sender's token balance
// feeStatus — FEE_PENDING, FEE_KNOWN or FEE_UNAVAILABLE. Anything else
@@ -128,6 +152,7 @@ function maxTokenAmount(tokenBalance) {
function validateTransfer({
isErc20 = false,
amount,
tokenDecimals = null,
ethBalance,
tokenBalance,
feeStatus = FEE_PENDING,
@@ -135,17 +160,17 @@ function validateTransfer({
} = {}) {
const codes = [];
const amountFp = toFixedPoint(amount);
const ethFp = toFixedPoint(ethBalance) ?? 0n;
// A negative amount parses to a valid bigint, so every comparison below
// is trivially false and the send clears the screen — then dies at encode
// time in parseEther(). Unusable, on the same footing as a malformed fee.
if (amountFp === null || amountFp < 0n) {
// An amount refused here never reaches the comparisons below: a negative
// one would pass every one of them, then die at encode time in
// parseEther().
if (!isValidAmount(amount, isErc20 ? tokenDecimals : null)) {
codes.push(CODES.AMOUNT_INVALID);
return { canSend: false, codes };
}
const amountFp = toFixedPoint(amount);
const ethFp = toFixedPoint(ethBalance) ?? 0n;
// Fail closed. Anything that is not a usable fee under a recognised
// status — a malformed feeWei, or a status this module does not know —
// is an unavailable estimate, never a fee of zero. Every such input errs
@@ -195,6 +220,7 @@ module.exports = {
SCALE_DECIMALS,
feeReserveWei,
feeEstimateWei,
isValidAmount,
maxEthAmount,
maxTokenAmount,
toFixedPoint,
+266 -124
View File
@@ -53,6 +53,11 @@ function formatAmount(raw, decimals) {
// on a scale — not as a token name, and not as a quantity.
const UNNAMED_CURRENCY = "Unknown (not named in the calldata)";
// The same refusal when a V4 swap action could not be read (see
// decodeExactly()). That action names both of its currencies, so saying the
// calldata did not name one would be false.
const UNDECODED_CURRENCY = "Unknown (could not be decoded)";
// Explicit presence, never truthiness. Every gate in this file that guards a
// decoded value goes through here: an address is never falsy once set, but an
// amount of 0n is, and a gate that cannot tell a genuine zero from an absent
@@ -285,7 +290,9 @@ const V4_SWAP_EXACT_IN = 0x07;
const V4_SWAP_EXACT_OUT_SINGLE = 0x08;
const V4_SWAP_EXACT_OUT = 0x09;
const V4_SETTLE = 0x0b;
const V4_SETTLE_ALL = 0x0c;
const V4_TAKE = 0x0e;
const V4_TAKE_ALL = 0x0f;
// A V4 exact-in `amountIn`, read the way V4Router reads it: zero is
// ActionConstants.OPEN_DELTA, not a quantity of zero. See the OPEN_DELTA
@@ -295,10 +302,151 @@ function v4ExactInAmount(raw) {
return raw === 0n ? OPEN_DELTA : raw;
}
// The V4 swap actions are read only in the parameter structs V4Router has
// deployed on mainnet and Sepolia. v4-periphery `main` has since added a
// `minHopPriceX36` field to all four (`src/interfaces/IV4Router.sol`), and
// reading a shape nobody has deployed would be guessing. ethers alone would
// read the newer structs anyway: it takes whatever word sits where a
// deployed field would be, ignores bytes left over, and holds back an error
// in a field until that field is used. The newer multi-hop exact-in struct
// then yields the offset of its hop-price array as `amountIn`. So a decode
// counts only when encoding the result again gives back the same bytes;
// otherwise this throws, and decodeV4Swap() marks the step as not decoded.
function decodeExactly(types, data) {
const decoded = coder.decode(types, data);
if (coder.encode(types, decoded) !== data) {
throw new Error("Not in the deployed V4 parameter shape.");
}
return decoded;
}
// Decode one V4 swap action's parameters into the currency it spends
// (`tokenIn`), the currency it buys (`tokenOut`) and, for an exact-in action,
// its `amountIn` and `amountOutMin`. The exact-out actions decode no amounts.
// A field the action does not state is null. Throws when the parameters are
// not in the deployed shape (see decodeExactly()).
function decodeV4SwapAction(actionId, param) {
if (actionId === V4_SWAP_EXACT_IN) {
// ExactInputParams: (address currencyIn,
// tuple(address,uint24,int24,address,bytes)[] path,
// uint128 amountIn, uint128 amountOutMin)
const s = decodeExactly(
[
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
],
param,
);
const path = s[0][1];
return {
tokenIn: s[0][0],
tokenOut: path.length > 0 ? path[path.length - 1][0] : null,
amountIn: v4ExactInAmount(s[0][2]),
amountOutMin: s[0][3],
};
}
if (actionId === V4_SWAP_EXACT_IN_SINGLE) {
// ExactInputSingleParams: (tuple(address,address,uint24,int24,address) poolKey,
// bool zeroForOne, uint128 amountIn, uint128 amountOutMin, bytes hookData)
const s = decodeExactly(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
param,
);
const poolKey = s[0][0];
const zeroForOne = s[0][1];
return {
tokenIn: zeroForOne ? poolKey[0] : poolKey[1],
tokenOut: zeroForOne ? poolKey[1] : poolKey[0],
amountIn: v4ExactInAmount(s[0][2]),
amountOutMin: s[0][3],
};
}
if (actionId === V4_SWAP_EXACT_OUT) {
// ExactOutputParams: (address currencyOut,
// tuple(address,uint24,int24,address,bytes)[] path,
// uint128 amountOut, uint128 amountInMaximum)
const s = decodeExactly(
[
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
],
param,
);
// The router works through the path from its last entry back to its
// first, starting from currencyOut, so the first entry is what the
// action spends.
const path = s[0][1];
return {
tokenIn: path.length > 0 ? path[0][0] : null,
tokenOut: s[0][0],
amountIn: null,
amountOutMin: null,
};
}
// V4_SWAP_EXACT_OUT_SINGLE. ExactOutputSingleParams: (poolKey as above,
// bool zeroForOne, uint128 amountOut, uint128 amountInMaximum,
// bytes hookData)
const s = decodeExactly(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
param,
);
const poolKey = s[0][0];
const zeroForOne = s[0][1];
return {
tokenIn: zeroForOne ? poolKey[0] : poolKey[1],
tokenOut: zeroForOne ? poolKey[1] : poolKey[0],
amountIn: null,
amountOutMin: null,
};
}
// The currencies in `list` that `other` does not hold, each listed once.
function currenciesNotIn(list, other) {
return [...new Set(list.filter((c) => !other.includes(c)))];
}
// The amount that the swap actions on one side of a V4 step state together:
// the one action's own amount, or the sum when there are several and each
// states a quantity. Exact-out actions state none here, and the open delta is
// not a quantity. Otherwise null.
function totalAmount(amounts) {
if (amounts.length === 1) return amounts[0];
if (amounts.length === 0) return null;
if (!amounts.every((a) => typeof a === "bigint")) return null;
return amounts.reduce((sum, a) => sum + a, 0n);
}
// Decode V4_SWAP (command 0x10) input bytes.
// The input is ABI-encoded as (bytes actions, bytes[] params).
// We extract token addresses from SETTLE (input) and TAKE (output) sub-actions,
// and swap amounts from the swap sub-actions.
// The input is ABI-encoded as (bytes actions, bytes[] params). Each swap
// action gives the currency it spends and the currency it buys (see
// decodeV4SwapAction()). The step's sides then follow three rules:
//
// 1. Token In is the currency SETTLE or SETTLE_ALL names; otherwise the one
// currency that some swap action spends and none buys. Token Out is the
// currency TAKE or TAKE_ALL names; otherwise the one currency that some
// swap action buys and none spends. If there is not exactly one such
// currency, that side is unknown.
// 2. Amount is the amountIn of the swap action that spends Token In. If
// several exact-in actions spend it, each with a stated amount, it is
// their sum; in any other case there is none. Min. received is the same
// for amountOutMin and the actions that buy Token Out.
// 3. If any swap action in the step cannot be read, only SETTLE, SETTLE_ALL,
// TAKE and TAKE_ALL name the sides, and there is no Amount or Min.
// received.
//
// A currency a swap action does not name, such as what an exact-in action
// with an empty path buys, is null and counts in rules 1 and 2 as a currency
// like any other: a step of only that action has an unknown Token Out and
// the action's amountOutMin as Min. received.
//
// `hasSwapAction` is true when the step has a swap action, read or not.
// decode() then always sets the output side from this step, and sets the
// input side from it only when no earlier step set it. A side it sets that
// these rules leave unknown is set unknown, with no figure. A step with no
// swap action sets only the sides SETTLE, SETTLE_ALL, TAKE and TAKE_ALL
// name.
function decodeV4Swap(input) {
try {
const d = coder.decode(["bytes", "bytes[]"], input);
@@ -307,8 +455,13 @@ function decodeV4Swap(input) {
let settleToken = null;
let takeToken = null;
let amountIn = null;
let amountOutMin = null;
// The step's swap actions that could be read, as decodeV4SwapAction()
// reads them.
const swaps = [];
// Set when a swap action's parameters are not in the deployed shape
// (see decodeExactly()), so nothing was read from that action. SETTLE,
// SETTLE_ALL, TAKE and TAKE_ALL may still name both currencies.
let swapUndecoded = false;
for (let i = 0; i < actions.length; i++) {
const actionId = actions[i];
@@ -320,6 +473,10 @@ function decodeV4Swap(input) {
params[i],
);
settleToken = s[0];
} else if (actionId === V4_SETTLE_ALL) {
// SETTLE_ALL: (address currency, uint256 maxAmount)
const s = coder.decode(["address", "uint256"], params[i]);
settleToken = s[0];
} else if (actionId === V4_TAKE) {
// TAKE: (address currency, address recipient, uint256 amount)
const t = coder.decode(
@@ -327,107 +484,20 @@ function decodeV4Swap(input) {
params[i],
);
takeToken = t[0];
} else if (actionId === V4_TAKE_ALL) {
// TAKE_ALL: (address currency, uint256 minAmount)
const t = coder.decode(["address", "uint256"], params[i]);
takeToken = t[0];
} else if (
actionId === V4_SWAP_EXACT_IN_SINGLE ||
actionId === V4_SWAP_EXACT_IN ||
actionId === V4_SWAP_EXACT_IN_SINGLE
actionId === V4_SWAP_EXACT_OUT_SINGLE ||
actionId === V4_SWAP_EXACT_OUT
) {
// Extract amounts from exact-in swap actions
if (actionId === V4_SWAP_EXACT_IN) {
// ExactInputParams: (address currencyIn,
// tuple(address,uint24,int24,address,bytes)[] path,
// uint128 amountIn, uint128 amountOutMin)
try {
const s = coder.decode(
[
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
],
params[i],
);
if (!present(settleToken)) settleToken = s[0][0];
const path = s[0][1];
if (path.length > 0 && !present(takeToken)) {
takeToken = path[path.length - 1][0];
}
if (!present(amountIn)) {
amountIn = v4ExactInAmount(s[0][2]);
}
if (!present(amountOutMin)) {
amountOutMin = s[0][3];
}
swaps.push(decodeV4SwapAction(actionId, params[i]));
} catch {
// Fall through — SETTLE/TAKE will provide tokens
}
} else {
// ExactInputSingleParams: (tuple(address,address,uint24,int24,address) poolKey,
// bool zeroForOne, uint128 amountIn, uint128 amountOutMin, bytes hookData)
try {
const s = coder.decode(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
params[i],
);
const poolKey = s[0][0];
const zeroForOne = s[0][1];
if (!present(settleToken))
settleToken = zeroForOne
? poolKey[0]
: poolKey[1];
if (!present(takeToken))
takeToken = zeroForOne
? poolKey[1]
: poolKey[0];
if (!present(amountIn)) {
amountIn = v4ExactInAmount(s[0][2]);
}
if (!present(amountOutMin)) {
amountOutMin = s[0][3];
}
} catch {
// Fall through
}
}
} else if (
actionId === V4_SWAP_EXACT_OUT ||
actionId === V4_SWAP_EXACT_OUT_SINGLE
) {
if (actionId === V4_SWAP_EXACT_OUT) {
try {
const s = coder.decode(
[
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
],
params[i],
);
if (!present(takeToken)) takeToken = s[0][0];
const path = s[0][1];
if (path.length > 0 && !present(settleToken)) {
settleToken = path[path.length - 1][0];
}
} catch {
// Fall through
}
} else {
try {
const s = coder.decode(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
params[i],
);
const poolKey = s[0][0];
const zeroForOne = s[0][1];
if (!present(settleToken))
settleToken = zeroForOne
? poolKey[0]
: poolKey[1];
if (!present(takeToken))
takeToken = zeroForOne
? poolKey[1]
: poolKey[0];
} catch {
// Fall through
}
swapUndecoded = true;
}
}
} catch {
@@ -435,11 +505,54 @@ function decodeV4Swap(input) {
}
}
// Rule 3.
if (swapUndecoded) {
return {
tokenIn: settleToken,
tokenOut: takeToken,
amountIn: null,
amountOutMin: null,
swapUndecoded,
hasSwapAction: true,
};
}
const spent = swaps.map((s) => s.tokenIn);
const bought = swaps.map((s) => s.tokenOut);
// Rules 1 and 2 for Token In.
let tokenIn = null;
let amountIn = null;
const spentOnly = currenciesNotIn(spent, bought);
if (present(settleToken) || spentOnly.length === 1) {
tokenIn = present(settleToken) ? settleToken : spentOnly[0];
amountIn = totalAmount(
swaps
.filter((s) => s.tokenIn === tokenIn)
.map((s) => s.amountIn),
);
}
// Rules 1 and 2 for Token Out.
let tokenOut = null;
let amountOutMin = null;
const boughtOnly = currenciesNotIn(bought, spent);
if (present(takeToken) || boughtOnly.length === 1) {
tokenOut = present(takeToken) ? takeToken : boughtOnly[0];
amountOutMin = totalAmount(
swaps
.filter((s) => s.tokenOut === tokenOut)
.map((s) => s.amountOutMin),
);
}
return {
tokenIn,
tokenOut,
amountIn,
amountOutMin,
swapUndecoded,
hasSwapAction: swaps.length > 0,
};
} catch {
return null;
@@ -463,8 +576,10 @@ function decode(data, toAddress, sources) {
let inputEstablished = false;
let outputToken = null;
let minOutput = null;
let outputEstablished = false;
let hasUnwrapWeth = false;
let hasV2ExactOut = false;
let swapUndecoded = false;
const commandNames = [];
// THE INVARIANT: an amount and the token it is counted in always come
@@ -481,8 +596,11 @@ function decode(data, toAddress, sources) {
//
// The input side is fixed by the first hop that states either half, the
// output side by the last, because the final leg is what the user
// receives. A half the establishing hop did not state stays null and
// the line says so, rather than being filled in from a different hop.
// receives. A V4 step with a swap action sets a side even when it
// states neither half: the output side always, and the input side
// when no earlier hop set it. A half the establishing hop did not
// state stays null and the line says so, rather than being filled in
// from a different hop.
const setInput = (token, amount) => {
inputToken = present(token) ? token : null;
inputAmount = present(amount) ? amount : null;
@@ -497,6 +615,7 @@ function decode(data, toAddress, sources) {
if (!present(token) && !present(amount)) return;
outputToken = present(token) ? token : null;
minOutput = present(amount) ? amount : null;
outputEstablished = true;
};
for (let i = 0; i < commandsBytes.length; i++) {
@@ -567,18 +686,34 @@ function decode(data, toAddress, sources) {
if (cmdId === 0x10) {
const v4 = decodeV4Swap(inputs[i]);
if (v4) {
if (v4 && v4.swapUndecoded) {
// A swap action of this step went unread, so the step
// is marked.
swapUndecoded = true;
commandNames[i] += " (could not be decoded)";
}
if (v4 && v4.hasSwapAction) {
// A step with a swap action always sets the output
// side, as the last swap step wins, and sets the
// input side only when no earlier step set it. Each
// side it sets is set as a pair, even when the step
// states neither half, so a side decodeV4Swap() left
// unknown is set unknown with no figure. A token
// stated without a figure, or a figure without a
// token, likewise never sits beside an earlier step's
// other half.
if (!inputEstablished) {
setInput(v4.tokenIn, v4.amountIn);
}
outputToken = v4.tokenOut;
minOutput = v4.amountOutMin;
outputEstablished = true;
} else if (v4) {
// A step with no swap action sets only the sides its
// SETTLE, SETTLE_ALL, TAKE or TAKE_ALL names:
// setInputOnce() and setOutput() leave alone a side
// whose two halves are both unstated.
setInputOnce(v4.tokenIn, v4.amountIn);
// Always update output: last swap step wins. A step
// that carries the Min. received figure but decoded no
// output currency makes the output *undetermined* — it
// is neither ETH nor whatever an earlier step named,
// and that figure is no longer counted in that token.
// Equally, a step that names an output currency but no
// minimum leaves Min. received unstated rather than
// keeping an earlier step's figure beside the new
// token. setOutput() is both rules; a step that states
// neither half leaves the output alone.
setOutput(v4.tokenOut, v4.amountOutMin);
}
}
@@ -591,19 +726,20 @@ function decode(data, toAddress, sources) {
}
}
// Resolve token info. A null token on either side means the calldata
// named no currency for it; tokenInfo() refuses rather than calling it
// ETH. UNWRAP_WETH turns WETH into ETH, so it makes the output ETH
// when the output side is WETH, or when no step set the output side.
// Any other output keeps its own token and figure: a swap that buys
// USDC and then unwraps the WETH it did not spend receives USDC.
// Resolve token info. A null token on either side means no step named
// a currency for it, or a V4 step with a swap action left it
// unknown; tokenInfo() refuses rather than calling it ETH.
// UNWRAP_WETH turns WETH into ETH, so it makes the output ETH when the
// output side is WETH, or when no step set the output side. A V4 step
// with a swap action did set it, even when it set it unknown. Any other
// output keeps its own token and figure: a swap that buys USDC and
// then unwraps the WETH it did not spend receives USDC.
const outputIsWeth =
present(outputToken) &&
WETH_ADDRESSES.includes(outputToken.toLowerCase());
const outputUnset = !present(outputToken) && !present(minOutput);
const inInfo = tokenInfo(inputToken, sources);
const outInfo =
hasUnwrapWeth && (outputIsWeth || outputUnset)
hasUnwrapWeth && (outputIsWeth || !outputEstablished)
? { symbol: "ETH", decimals: 18, address: null }
: tokenInfo(outputToken, sources);
@@ -640,7 +776,10 @@ function decode(data, toAddress, sources) {
// rather than going missing or naming a token by default. Same
// wording as the Token Out refusal below: the two sides of this
// screen must not describe the same condition in two ways.
details.push({ label: "Token In", value: UNNAMED_CURRENCY });
details.push({
label: "Token In",
value: swapUndecoded ? UNDECODED_CURRENCY : UNNAMED_CURRENCY,
});
}
if (present(inputAmount)) {
@@ -703,7 +842,10 @@ function decode(data, toAddress, sources) {
// rather than going missing or naming a token by default, and a
// Min. received figure below it is never attached to a token the
// calldata did not state.
details.push({ label: "Token Out", value: UNNAMED_CURRENCY });
details.push({
label: "Token Out",
value: swapUndecoded ? UNDECODED_CURRENCY : UNNAMED_CURRENCY,
});
}
if (present(minOutput)) {
+1 -1
View File
@@ -14,7 +14,7 @@ function sameAddress(a, b) {
// Forget every site permission held against the given addresses: the
// remembered ones in `state`, and the connections approved without
// "Remember", which only the background holds, in memory.
// "Remember", which only the background holds, in session storage.
function dropSitePermissions(state, addresses) {
for (const addr of addresses) {
delete state.allowedSites[addr];
+167
View File
@@ -0,0 +1,167 @@
// A page whose origin has no host is never told an address by accountsChanged
// (https://git.eeqj.de/sneak/AutistMask/issues/564).
//
// When the user switches address, the background tells each open tab the new
// active address if the site at the tab's URL is connected. A page at a
// connected site's URL can still have another origin: one its server
// sandboxes with a Content-Security-Policy has the origin "null", and every
// request it makes is refused. Each test here switches the address in the real
// background, takes the event it sent to a tab at the site's URL, and hands
// that event to the real content script in a page of the given origin.
const { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub");
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const SITE = "https://dapp.example";
const EXT_URL = "chrome-extension://autistmask/";
const FROM_POPUP = { url: EXT_URL + "src/popup/index.html" };
// One address, with SITE remembered as allowed for it.
function storedProfile() {
const mainnet = networkById("mainnet");
return {
hasWallet: true,
wallets: [
{
name: "Wallet 1",
type: "hd",
addresses: [
{ address: ADDRESS, balance: "0", tokenBalances: [] },
],
},
],
activeAddress: ADDRESS,
networkId: "mainnet",
rpcUrl: mainnet.defaultRpcUrl,
blockscoutUrl: mainnet.defaultBlockscoutUrl,
allowedSites: { [ADDRESS]: [SITE] },
deniedSites: {},
trackedTokens: [],
};
}
async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
afterEach(() => {
delete global.chrome;
delete global.window;
jest.resetModules();
});
// The popup tells the background the active address changed, and the one
// open tab, at SITE's URL, is sent an event. That event is returned.
async function eventSentToSiteTab() {
jest.resetModules();
jest.doMock("../src/shared/balances", () => ({
getProvider: () => ({}),
refreshBalances: jest.fn(async () => {}),
}));
jest.doMock("../src/shared/phishingDomains", () => ({
isPhishingDomain: () => false,
}));
jest.doMock("../src/shared/alarms", () => ({
BALANCE_REFRESH_ALARM: "balance",
BALANCE_REFRESH_PERIOD_MINUTES: 1,
ensureRecurringAlarms: jest.fn(async () => {}),
registerAlarmHandlers: jest.fn(),
}));
let messageListener = null;
const sentToTabs = [];
global.chrome = {
storage: makeStorageStub({ autistmask: storedProfile() }),
runtime: {
getURL: (path) => EXT_URL + path,
onMessage: {
addListener: (fn) => {
messageListener = fn;
},
},
onConnect: { addListener: () => {} },
getPlatformInfo: (cb) => cb({ os: "linux" }),
lastError: null,
},
windows: {
remove: (id, cb) => {
if (cb) cb();
},
onRemoved: { addListener: () => {} },
},
tabs: {
query: (q, cb) => cb([{ id: 1, url: SITE + "/app" }]),
sendMessage: (tabId, msg, cb) => {
sentToTabs.push(msg);
if (cb) cb();
},
},
action: { setPopup: () => {} },
};
require("../src/background/index");
messageListener(
{ type: "AUTISTMASK_ACTIVE_CHANGED" },
FROM_POPUP,
() => {},
);
await settle();
expect(sentToTabs).toHaveLength(1);
return sentToTabs[0];
}
// The content script in a page whose origin is `pageOrigin` is handed `event`
// from the background. What it posts to the page is returned.
function postedToPage(event, pageOrigin) {
jest.resetModules();
let eventListener = null;
const posted = [];
global.chrome = {
runtime: {
onMessage: {
addListener: (fn) => {
eventListener = fn;
},
},
},
};
global.window = {
origin: pageOrigin,
addEventListener: () => {},
postMessage: (data) => posted.push(data),
};
require("../src/content/index");
eventListener(event);
return posted;
}
describe("accountsChanged after an address switch, at a connected site's URL", () => {
test("the site's own page is told the active address", async () => {
const event = await eventSentToSiteTab();
expect(postedToPage(event, SITE)).toEqual([
{
type: "AUTISTMASK_EVENT",
eventName: "accountsChanged",
data: [ADDRESS],
},
]);
});
test("a page whose origin has no host is told no address", async () => {
const event = await eventSentToSiteTab();
expect(postedToPage(event, "null")).toEqual([
{
type: "AUTISTMASK_EVENT",
eventName: "accountsChanged",
data: [],
},
]);
});
});
+1 -1
View File
@@ -230,7 +230,7 @@ beforeEach(() => {
addWallet.init({
renderWalletList: () => {},
doRefreshAndRender: () => {},
startRefresh: () => {},
pageClosed: new AbortController().signal,
});
confirmTx.init({});
+1 -1
View File
@@ -94,7 +94,7 @@ describe.each([
async function create(pageClosed) {
addWallet.init({
renderWalletList: () => {},
doRefreshAndRender: () => {},
startRefresh: () => {},
pageClosed,
});
element("tab-" + mode).listeners.click();
+39
View File
@@ -0,0 +1,39 @@
// The confirmation screen's warning for a recipient that has never sent a
// transaction (https://git.eeqj.de/sneak/AutistMask/issues/528).
//
// The node's transaction count for an address is the number of transactions
// it has sent, so an address that only ever receives, such as a savings
// address or an exchange deposit address, has a count of zero. The warning
// used to fire on that count alone. It now also needs a zero balance: ETH an
// address received stays in its balance until it sends a transaction.
"use strict";
const { getFullWarnings } = require("../src/shared/addressWarnings");
global.fetch = jest.fn(async () => {
throw new Error("tests must not perform network requests");
});
const RECIPIENT = "0x" + "b".repeat(40);
// The warning types getFullWarnings() returns for RECIPIENT when the node
// reports it as a regular address with `txCount` sent transactions and a
// balance of `balance` wei.
async function warningTypes(txCount, balance) {
const provider = {
getCode: async () => "0x",
getTransactionCount: async () => txCount,
getBalance: async () => balance,
};
const warnings = await getFullWarnings(RECIPIENT, provider);
return warnings.map((w) => w.type);
}
test("a recipient that has received 0.5 ETH and never sent a transaction gets no warning", async () => {
expect(await warningTypes(0, 500000000000000000n)).toEqual([]);
});
test("a recipient that has never sent a transaction and holds no ETH gets the warning", async () => {
expect(await warningTypes(0, 0n)).toEqual(["new-address"]);
});
+129
View File
@@ -45,3 +45,132 @@ test("the approval window shows no screen until the background describes the app
expect(env.visibleViews()).toEqual(["approve-network"]);
expect(env.pageErrors).toEqual([]);
});
// The background opens the approval window as wide as the page, but the
// browser counts the window's frame in that width, and a screen taller than
// the window takes a vertical scrollbar out of it too
// (https://git.eeqj.de/sneak/AutistMask/issues/518). The browser suites run
// without frames, and only the Firefox suite's sign prompt at a zoom other
// than 100%, so the widening is checked here too.
//
// A window 396 screen pixels wide whose page is drawn at `drawn` screen pixels
// to the page pixel. Widening it gives the page its new width, less the 15
// screen pixels of the scrollbar the approval screen has, and then calls the
// page's resize listeners, each of which asked to be called once.
function windowsStub(type, drawn) {
const updates = [];
return {
updates,
getCurrent: (done) => done({ id: 7, type, width: 396 }),
update: (id, info, done) => {
updates.push({ id, info });
done({});
globalThis.document.documentElement.clientWidth = Math.floor(
(info.width - 15) / drawn,
);
const resized = globalThis.window.listeners.resize || [];
globalThis.window.listeners.resize = [];
for (const fn of resized) fn();
},
};
}
// The approval page's own tab is at `zoom`. Asked without a tab id, Firefox
// answers for the window focused last, here a site's tab at 100%.
function tabsStub(zoom) {
return {
getCurrent: (done) => done({ id: 11 }),
getZoom: (...args) => {
const done = args.pop();
done(args[0] === 11 ? zoom : 1);
},
};
}
// An approval window at `zoom`, `clientWidth` page pixels wide before its
// screen is up, whose background holds its answer until the test gives it.
async function bootZoomedApprovalWindow(windows, zoom, clientWidth) {
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;
},
},
windows,
tabs: tabsStub(zoom),
documentElement: { scrollWidth: 396, clientWidth },
});
return { env, answer };
}
test("at 150% zoom the approval window widens by what its screen lacks, scrollbar included", async () => {
const windows = windowsStub("popup", 1.5);
// At 150% a window 396 screen pixels wide shows 264 page pixels.
const { env, answer } = await bootZoomedApprovalWindow(windows, 1.5, 264);
// Not before there is a screen to fit.
expect(windows.updates).toEqual([]);
// The screen is taller than the window, so once it is up a vertical
// scrollbar takes 15 screen pixels, 10 page pixels at 150%.
env.document.documentElement.clientWidth = 254;
answer({
type: "network",
origin: "https://dapp.example",
currentNetworkId: "mainnet",
requestedNetworkId: "sepolia",
isPhishingDomain: false,
});
await settle();
// 396 - 254 = 142 page pixels short, which at 150% is 213 screen pixels.
// At the 100% of the site's tab it would be 142, and the screen would
// still not fit.
expect(env.visibleViews()).toEqual(["approve-network"]);
expect(windows.updates).toEqual([{ id: 7, info: { width: 609 } }]);
expect(env.document.documentElement.clientWidth).toBe(396);
expect(env.pageErrors).toEqual([]);
});
test("at 170% the approval window is widened again while its screen still falls short", async () => {
// Firefox reports 170% as 1.7 but draws a page pixel about 1.714 screen
// pixels wide, so a window 396 wide shows 231 page pixels.
const windows = windowsStub("popup", 1.714);
const { env, answer } = await bootZoomedApprovalWindow(windows, 1.7, 231);
// Less the scrollbar once the screen is up.
env.document.documentElement.clientWidth = 222;
answer({
type: "network",
origin: "https://dapp.example",
currentNetworkId: "mainnet",
requestedNetworkId: "sepolia",
isPhishingDomain: false,
});
await settle();
// 174 page pixels short, so 296 screen pixels wider at 1.7. That shows 394
// page pixels, still 2 short, so 4 screen pixels wider again.
expect(windows.updates).toEqual([
{ id: 7, info: { width: 692 } },
{ id: 7, info: { width: 696 } },
]);
const page = env.document.documentElement;
expect(page.scrollWidth).toBeLessThanOrEqual(page.clientWidth);
expect(env.pageErrors).toEqual([]);
});
test("a connection prompt in the toolbar popup leaves the browser window alone", async () => {
const windows = windowsStub("normal", 1.5);
const { env, answer } = await bootZoomedApprovalWindow(windows, 1.5, 264);
answer({ origin: "https://dapp.example", isPhishingDomain: false });
await settle();
expect(env.visibleViews()).toEqual(["approve-site"]);
expect(windows.updates).toEqual([]);
expect(env.pageErrors).toEqual([]);
});
+115 -18
View File
@@ -264,6 +264,7 @@ function loadBackground(options) {
connectListener = fn;
},
},
getPlatformInfo: jest.fn((cb) => cb({ os: "linux" })),
lastError: null,
},
windows: {
@@ -320,9 +321,10 @@ function loadBackground(options) {
require("../src/background/index");
// Send a message the way the browser would, and hand back whatever the
// handler passed to sendResponse.
function send(msg, sender) {
const sendResponse = jest.fn();
// handler passed to sendResponse. `onResponse`, when given, receives the
// answer when the handler sends it, as the sender's callback does.
function send(msg, sender, onResponse) {
const sendResponse = jest.fn(onResponse);
const kept = messageListener(msg, sender || {}, sendResponse);
return { sendResponse, kept };
}
@@ -384,13 +386,13 @@ function loadBackground(options) {
// The same through eth_signTypedData_v4, whose params name the address
// first and the typed data second.
function requestTypedData() {
function requestTypedData(typedData = TYPED_DATA) {
let rpcResult = null;
messageListener(
{
type: "AUTISTMASK_RPC",
method: "eth_signTypedData_v4",
params: [signer.address, TYPED_DATA],
params: [signer.address, typedData],
},
{ origin: ORIGIN },
(r) => {
@@ -878,6 +880,45 @@ describe("one transaction approval at a time", () => {
});
});
// Chrome stops an idle worker, and the approvals in it go with it
// (https://git.eeqj.de/sneak/AutistMask/issues/540). tests/e2e/slowApproval.js
// shows the worker kept running in Chrome; this shows the calls stop once the
// approval is answered, so the worker can stop again. Every kind of approval is
// added through addPendingApproval(), which starts the calls, so a transaction
// approval stands for all of them.
describe("the worker keeps running while an approval is pending", () => {
test("an extension call every 20 seconds until the approval is answered", async () => {
const startTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
const stopTimer = jest
.spyOn(global, "clearInterval")
.mockImplementation(() => {});
try {
const bg = loadBackground();
const calls = global.chrome.runtime.getPlatformInfo;
bg.requestTx();
await settle();
expect(startTimer).toHaveBeenCalledTimes(1);
expect(startTimer.mock.calls[0][1]).toBe(20000);
const tick = startTimer.mock.calls[0][0];
tick();
expect(calls).toHaveBeenCalledTimes(1);
// The user closes the approval window, which rejects it.
bg.closeWindow(1);
await settle();
tick();
expect(calls).toHaveBeenCalledTimes(1);
expect(stopTimer).toHaveBeenCalledWith(1);
} finally {
startTimer.mockRestore();
stopTimer.mockRestore();
}
});
});
// A page that asks again before the user has answered its last connection or
// signature request is refused, instead of opening one more window per call.
describe("one connection and one signature approval per site at a time", () => {
@@ -1121,9 +1162,12 @@ describe("a nonce collision is reported as a transaction that did not go out", (
const pending = bg.requestTx();
await settle();
// As ethers throws it: its own classification, and the node's
// JSON-RPC error under info.error.
bg.broadcastTransaction.mockRejectedValue(
Object.assign(new Error("nonce too low"), {
code: "NONCE_EXPIRED",
info: { error: { code: -32000, message: "nonce too low" } },
}),
);
const answer = bg.send(
@@ -1142,8 +1186,10 @@ describe("a nonce collision is reported as a transaction that did not go out", (
retryable: false,
stage: "nonce",
});
// The wallet's sentence, with the node's own code.
expect(pending.result()).toEqual({
error: {
code: -32000,
message: expect.stringMatching(
/transaction was not sent, because its nonce/,
),
@@ -1193,6 +1239,7 @@ describe("a nonce collision is reported as a transaction that did not go out", (
});
expect(second.result()).toEqual({
error: {
code: -32603,
message: expect.stringMatching(/nonce had already been used/),
},
});
@@ -1283,6 +1330,7 @@ describe("what the approval is verified against", () => {
);
expect(pending.result()).toEqual({
error: {
code: -32603,
message: expect.stringMatching(/approved maximum fee per gas/),
},
});
@@ -1342,7 +1390,10 @@ describe("what the approval is verified against", () => {
expect.objectContaining({ retryable: false, stage: "verify" }),
);
expect(pending.result()).toEqual({
error: { message: expect.stringMatching(/active address changed/) },
error: {
code: -32603,
message: expect.stringMatching(/active address changed/),
},
});
});
@@ -1486,7 +1537,10 @@ describe("what the approval is verified against", () => {
}),
);
expect(pending.result()).toEqual({
error: { message: expect.stringMatching(/active address changed/) },
error: {
code: -32603,
message: expect.stringMatching(/active address changed/),
},
});
});
@@ -1494,8 +1548,8 @@ describe("what the approval is verified against", () => {
// 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);
async function answerTypedData(bg, pending, typedData = TYPED_DATA) {
const { domain, types, message } = JSON.parse(typedData);
delete types.EIP712Domain;
const signature = await signer.signTypedData(domain, types, message);
const answer = bg.send(
@@ -1536,8 +1590,48 @@ describe("what the approval is verified against", () => {
error: refusal,
retryable: false,
});
expect(pending.result()).toEqual({ error: { message: refusal } });
expect(pending.result()).toEqual({
error: { code: -32603, message: refusal },
});
});
// ethers signs a `bool` field sent as the string "false" as true, and one
// left out as false. The sign screen refuses both; a signature for either
// that reaches the background anyway is not handed to the page
// (https://git.eeqj.de/sneak/AutistMask/issues/552).
test.each([
[
'"false"',
"false",
'The field "approved" is not true or false, so this typed data cannot be signed.',
],
[
"left out",
undefined,
'The field "approved" is missing from the message, so this typed data cannot be signed.',
],
])(
"typed data with a bool %s is refused, not handed back",
async (_, approved, refusal) => {
const data = JSON.parse(TYPED_DATA);
data.types.Note.push({ name: "approved", type: "bool" });
data.message.approved = approved;
const typedData = JSON.stringify(data);
const bg = loadBackground();
const pending = bg.requestTypedData(typedData);
await settle();
const { answer } = await answerTypedData(bg, pending, typedData);
expect(answer.sendResponse).toHaveBeenCalledWith({
error: refusal,
retryable: false,
});
expect(pending.result()).toEqual({
error: { code: -32603, message: refusal },
});
},
);
test("a sign request from another address raises no approval at all", async () => {
const bg = loadBackground();
@@ -1573,6 +1667,7 @@ describe("what the approval is verified against", () => {
expect(pending.result()).toEqual({
error: {
code: -32603,
message: expect.stringMatching(
/active address changed while this transaction was being prepared/,
),
@@ -1595,8 +1690,11 @@ describe("what the approval is verified against", () => {
const pending = bg.requestTx();
await settle();
// A failure with no node error in it is answered as the wallet's own;
// tests/rpcErrorCodes.test.js has the node's own revert.
expect(pending.result()).toEqual({
error: {
code: -32603,
message: expect.stringMatching(
/could not be prepared.*execution reverted/,
),
@@ -2518,9 +2616,9 @@ async function siteAccounts(bg, origin) {
return sendResponse.mock.calls[0][0];
}
// A site connected without "Remember" is held only in the background's memory,
// keyed to the address it was connected to. Removing that address, or the
// wallet holding it, must end the connection as part of the removal itself.
// A site connected without "Remember" is held only in the background's session
// storage, keyed to the address it was connected to. Removing that address, or
// the wallet holding it, must end the connection as part of the removal itself.
// The accountsChanged broadcast the views send afterwards also clears it, but
// only when the active address moved, and nothing waits for it to arrive.
//
@@ -2699,8 +2797,7 @@ describe("removing a site in Settings disconnects it", () => {
const element = (id) => (lists[id] ||= fakeSiteList());
global.document = { getElementById: element };
global.chrome.runtime.sendMessage = (msg, callback) => {
const { sendResponse } = bg.send(msg, bg.fromPopup);
if (callback) callback(sendResponse.mock.calls[0]?.[0]);
bg.send(msg, bg.fromPopup, callback);
};
await require("../src/shared/state").loadState();
await require("../src/popup/views/settings").renderSiteLists();
@@ -2854,7 +2951,7 @@ describe("where an approval window opens", () => {
await settle();
expect(bg.created).toHaveLength(1);
expect(bg.created[0]).toMatchObject({ left: 460, top: 60 });
expect(bg.created[0]).toMatchObject({ left: 442, top: 60 });
});
test("not centred on an approval window the user was last in", async () => {
@@ -2871,7 +2968,7 @@ describe("where an approval window opens", () => {
bg.requestSign();
await settle();
// Centred, it would be at left 900, the position the browser refused.
// Centred, it would be at left 882, the position the browser refused.
expect(bg.created).toHaveLength(1);
expect(bg.created[0].left).toBeUndefined();
expect(bg.created[0].top).toBeUndefined();
@@ -2893,7 +2990,7 @@ describe("where an approval window opens", () => {
await settle();
expect(bg.created).toHaveLength(2);
expect(bg.created[0]).toMatchObject({ left: 1520, top: 750 });
expect(bg.created[0]).toMatchObject({ left: 1502, top: 750 });
expect(bg.created[1].left).toBeUndefined();
expect(bg.created[1].top).toBeUndefined();
+354
View File
@@ -0,0 +1,354 @@
// A site allowed without "Remember my choice for this site" stays connected
// when the background worker is stopped and started again
// (https://git.eeqj.de/sneak/AutistMask/issues/526).
//
// Chrome stops the worker after about 30 seconds idle and evaluates the
// module afresh on the next event, so nothing held at module scope survives
// it. Such a connection used to be held exactly there, and so lasted until the
// first idle spell. Each test here makes the connection in one load of the
// background, loads the module afresh over the same storage, as the browser
// does, and asks the new one. The paths that end a connection are driven after
// that restart, so each is shown to end what the earlier worker stored.
const { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub");
const {
removeAddressFromState,
removeWalletFromState,
} = require("../src/shared/walletDelete");
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const OTHER_ADDRESS = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8";
const SITE = "https://dapp.example";
const OTHER_SITE = "https://other.example";
const EXT_URL = "chrome-extension://autistmask/";
const FROM_POPUP = { url: EXT_URL + "src/popup/index.html" };
// One wallet with two addresses, so that one of them can be removed, and no
// remembered sites, so that every connection here is one made without
// "Remember".
function storedProfile() {
const mainnet = networkById("mainnet");
return {
hasWallet: true,
wallets: [
{
name: "Wallet 1",
type: "hd",
addresses: [
{ address: ADDRESS, balance: "0", tokenBalances: [] },
{ address: OTHER_ADDRESS, balance: "0", tokenBalances: [] },
],
},
],
activeAddress: ADDRESS,
networkId: "mainnet",
rpcUrl: mainnet.defaultRpcUrl,
blockscoutUrl: mainnet.defaultBlockscoutUrl,
allowedSites: {},
deniedSites: {},
trackedTokens: [],
};
}
async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
afterEach(() => {
delete global.chrome;
jest.resetModules();
});
// Load the background module afresh, as the browser does when it starts the
// worker, over `storage`. The storage stub stands in for the browser's own
// storage, local and session, so it outlives each load.
function startWorker(storage) {
jest.resetModules();
jest.doMock("../src/shared/balances", () => ({
getProvider: () => ({}),
refreshBalances: jest.fn(async () => {}),
}));
jest.doMock("../src/shared/phishingDomains", () => ({
isPhishingDomain: () => false,
}));
jest.doMock("../src/shared/alarms", () => ({
BALANCE_REFRESH_ALARM: "balance",
BALANCE_REFRESH_PERIOD_MINUTES: 1,
ensureRecurringAlarms: jest.fn(async () => {}),
registerAlarmHandlers: jest.fn(),
}));
let messageListener = null;
let connectListener = null;
// Every URL the toolbar popup was set to open: a site's connection prompt
// is shown there, so this is where its approval id appears.
const toolbarPopups = [];
const windowsCreated = [];
const sentToTabs = [];
global.chrome = {
storage,
runtime: {
getURL: (path) => EXT_URL + path,
onMessage: {
addListener: (fn) => {
messageListener = fn;
},
},
onConnect: {
addListener: (fn) => {
connectListener = fn;
},
},
getPlatformInfo: (cb) => cb({ os: "linux" }),
// What the popup sends reaches the worker from the extension's own
// page.
sendMessage: (msg, callback) => {
messageListener(msg, FROM_POPUP, callback || (() => {}));
},
lastError: null,
},
windows: {
getLastFocused: (cb) => cb(null),
create: (options, cb) => {
windowsCreated.push(options);
cb({ id: windowsCreated.length });
},
remove: (id, cb) => {
if (cb) cb();
},
onRemoved: { addListener: () => {} },
},
tabs: {
query: (q, cb) => cb([{ id: 1, url: SITE + "/app" }]),
sendMessage: (tabId, msg, cb) => {
sentToTabs.push(msg);
if (cb) cb();
},
},
action: {
setPopup: (o) => {
toolbarPopups.push(o.popup);
},
openPopup: () => Promise.resolve(),
},
};
require("../src/background/index");
// A request from a site, answered once the worker has had time to answer.
async function rpc(method, origin, params) {
let response = null;
messageListener(
{ type: "AUTISTMASK_RPC", method, params: params || [] },
{ origin },
(r) => {
response = r;
},
);
await settle();
return response;
}
// A message from the popup, and the worker's answer to it.
async function fromPopup(msg) {
let response;
messageListener(msg, FROM_POPUP, (r) => {
response = r;
});
await settle();
return response;
}
// The site asks to connect, and the user allows it in the prompt with
// "Remember my choice for this site" left unchecked.
async function connectWithoutRemember(origin) {
let response = null;
messageListener(
{ type: "AUTISTMASK_RPC", method: "eth_requestAccounts" },
{ origin },
(r) => {
response = r;
},
);
await settle();
const id = new URL(toolbarPopups.at(-1), EXT_URL).searchParams.get(
"approval",
);
const decisionListeners = [];
const port = {
name: "approval:" + id,
sender: { url: EXT_URL + "src/popup/index.html?approval=" + id },
onMessage: { addListener: (fn) => decisionListeners.push(fn) },
onDisconnect: { addListener: () => {} },
};
connectListener(port);
for (const fn of decisionListeners) {
fn(
{
type: "AUTISTMASK_APPROVAL_DECISION",
approved: true,
remember: false,
},
port,
);
}
await settle();
return response;
}
return {
rpc,
fromPopup,
connectWithoutRemember,
windowsCreated,
sentToTabs,
};
}
// A site connected without "Remember" in one worker, and the worker started
// again over the same storage, where the site is still connected.
async function connectedThenRestarted() {
const storage = makeStorageStub({ autistmask: storedProfile() });
const first = startWorker(storage);
expect(await first.connectWithoutRemember(SITE)).toEqual({
result: [ADDRESS],
});
const worker = startWorker(storage);
expect(await worker.rpc("eth_accounts", SITE)).toEqual({
result: [ADDRESS],
});
return { storage, worker };
}
describe("a site allowed without Remember, after the worker restarts", () => {
test("is still told the address, and is not asked again", async () => {
const { worker } = await connectedThenRestarted();
expect(await worker.rpc("eth_accounts", SITE)).toEqual({
result: [ADDRESS],
});
expect(await worker.rpc("eth_requestAccounts", SITE)).toEqual({
result: [ADDRESS],
});
});
test("is still listed under Connected Sites in Settings", async () => {
const { worker } = await connectedThenRestarted();
expect(
await worker.fromPopup({ type: "AUTISTMASK_GET_CONNECTED_SITES" }),
).toEqual([SITE]);
});
test("may still ask for a signature", async () => {
// Refused with 4100 before the fix. Allowed, it raises the signing
// prompt and waits on the user.
const { worker } = await connectedThenRestarted();
const response = await worker.rpc("personal_sign", SITE, [
"0x48656c6c6f",
ADDRESS,
]);
expect(response).toBeNull();
expect(worker.windowsCreated).toHaveLength(1);
});
test("is not connected after the browser restarts", async () => {
// The browser empties session storage when it closes; the wallet's
// own storage keeps nothing of the connection.
const { storage } = await connectedThenRestarted();
const afterBrowserRestart = startWorker(
makeStorageStub({ autistmask: storage.read("autistmask") }),
);
expect(await afterBrowserRestart.rpc("eth_accounts", SITE)).toEqual({
result: [],
});
});
});
describe("each way a connection allowed without Remember ends, after the worker restarts", () => {
test("removing the site in Settings", async () => {
const { worker } = await connectedThenRestarted();
expect(await worker.connectWithoutRemember(OTHER_SITE)).toEqual({
result: [ADDRESS],
});
await worker.fromPopup({
type: "AUTISTMASK_REMOVE_SITE",
origin: SITE,
});
expect(await worker.rpc("eth_accounts", SITE)).toEqual({ result: [] });
expect(worker.sentToTabs).toEqual([
{
type: "AUTISTMASK_EVENT",
eventName: "accountsChanged",
data: [],
},
]);
expect(
await worker.fromPopup({ type: "AUTISTMASK_GET_CONNECTED_SITES" }),
).toEqual([OTHER_SITE]);
expect(await worker.rpc("eth_accounts", OTHER_SITE)).toEqual({
result: [ADDRESS],
});
});
test("switching to another address", async () => {
// Switched away and back again, so the answer turns on the connection
// having ended rather than on which address is active.
const { storage, worker } = await connectedThenRestarted();
const switched = { ...storage.read("autistmask") };
switched.activeAddress = OTHER_ADDRESS;
storage.write("autistmask", switched);
await worker.fromPopup({ type: "AUTISTMASK_ACTIVE_CHANGED" });
switched.activeAddress = ADDRESS;
storage.write("autistmask", switched);
expect(await worker.rpc("eth_accounts", SITE)).toEqual({ result: [] });
expect(
await worker.fromPopup({ type: "AUTISTMASK_GET_CONNECTED_SITES" }),
).toEqual([]);
});
// The two below ask while storage still names the removed address as
// active, because the popup has not saved yet, so the answer turns on the
// connection alone.
test("removing the connected address", async () => {
const { storage, worker } = await connectedThenRestarted();
const popupState = storage.read("autistmask");
expect(removeAddressFromState(popupState, 0, 0).removed).toBe(true);
await settle();
expect(await worker.rpc("eth_accounts", SITE)).toEqual({ result: [] });
});
test("removing the wallet holding the connected address", async () => {
const { storage, worker } = await connectedThenRestarted();
removeWalletFromState(storage.read("autistmask"), 0);
await settle();
expect(await worker.rpc("eth_accounts", SITE)).toEqual({ result: [] });
});
test("removing a different address leaves it connected", async () => {
const { storage, worker } = await connectedThenRestarted();
const popupState = storage.read("autistmask");
expect(removeAddressFromState(popupState, 0, 1).removed).toBe(true);
await settle();
expect(await worker.rpc("eth_accounts", SITE)).toEqual({
result: [ADDRESS],
});
});
});
+24 -20
View File
@@ -1,25 +1,29 @@
// A loopback dApp origin and stub Ethereum node for the Firefox suite.
// A loopback dApp origin and stub Ethereum node, used by the two browser
// programs that have no Playwright to answer the browser's requests for them:
// the Firefox suite (tests/e2e/firefox/run.js) and the Chrome program that
// leaves approvals open for seven minutes (tests/e2e/slowApproval.js). The
// Chrome suite in run.js answers its requests through network.js instead.
//
// The Firefox container runs with --network none, and the harness note in
// driver.js records the consequence: with no http:// origin in reach, no
// content script was ever injected, so content-script behaviour was
// firefox/driver.js records the consequence: with no http:// origin in reach,
// no content script was ever injected, so content-script behaviour was
// UNVERIFIED and the dApp flows could not be driven at all.
//
// --network none removes every interface except loopback, and loopback is
// enough. This serves the page and the JSON-RPC endpoint from 127.0.0.1
// inside the same container Firefox runs in, so the dApp round trips execute
// against a real http:// origin and the run stays as offline as it was: the
// only reachable peer is this process.
// inside the same container the browser runs in, so the dApp round trips
// execute against a real http:// origin and the run stays as offline as it
// was: the only reachable peer is this process.
//
// The page itself is not written twice. DAPP_HTML comes from the Chrome
// suite's fixture, so both harnesses drive the same __dapp API and the same
// message log.
// suite's fixture, so every browser program drives the same __dapp API and
// the same message log.
//
// Unlike driver.js this file does use ethers, and it has to: the node has to
// answer eth_sendRawTransaction with the hash ethers computes for the
// Unlike firefox/driver.js this file does use ethers, and it has to: the node
// has to answer eth_sendRawTransaction with the hash ethers computes for the
// artifact it was handed, or provider.broadcastTransaction() refuses the
// answer, and the suite recovers signatures itself rather than believing the
// extension's own verdict.
// answer, and both programs recover signatures themselves rather than
// believing the extension's own verdict.
"use strict";
@@ -27,7 +31,7 @@ const http = require("http");
const { Transaction } = require("ethers");
const { DAPP_HTML } = require("../network");
const { DAPP_HTML } = require("./network");
// The same fee shape the Chrome suite uses, for the same reason: it has to
// pass the ceilings in src/shared/approvalVerify.js and it has to leave the
@@ -170,11 +174,11 @@ async function startDappServer() {
const payload = JSON.stringify(handleRpcBody(body, state));
res.writeHead(200, {
"Content-Type": "application/json",
// The extension fetches this from its background
// page, whose origin is moz-extension://. Without CORS
// the fetch fails and every transaction assertion
// fails for a reason that has nothing to do with the
// wallet.
// In Firefox the extension fetches this from its
// background page, whose origin is moz-extension://.
// Without CORS the fetch fails and every transaction
// assertion fails for a reason that has nothing to do
// with the wallet.
"Access-Control-Allow-Origin": "*",
});
res.end(payload);
@@ -193,8 +197,8 @@ async function startDappServer() {
}
// An empty favicon rather than a 404: a 404 is a page error in
// Firefox's console under some settings, and the suite fails the run
// on those.
// Firefox's console under some settings, and the Firefox suite fails
// the run on those.
if (url.pathname === "/favicon.ico") {
res.writeHead(200, { "Content-Type": "image/x-icon" });
res.end("");
+1 -1
View File
@@ -467,7 +467,7 @@ class Driver {
// src/background/index.js, which kills the background page outright, fails
// the run.
//
// Content scripts ARE now exercised: tests/e2e/firefox/dapp.js serves a page
// Content scripts ARE now exercised: tests/e2e/dapp.js serves a page
// from loopback, which survives --network none, and the suite drives the
// EIP-1193 round trips through the content script injected into it. What is
// still unproven is the CAPTURE, not the execution — no probe has forced a
+39 -4
View File
@@ -17,13 +17,13 @@
// note in driver.js), so the two backends have no common substrate to
// abstract over. Duplicated steps do not pay for a shim; revisit if this
// suite grows to where they do. What IS shared is the dApp page fixture
// itself — DAPP_HTML, served here from loopback by dapp.js — so an assertion
// about the __dapp API means the same thing on both browsers.
// itself — DAPP_HTML, served here from loopback by tests/e2e/dapp.js — so an
// assertion about the __dapp API means the same thing on both browsers.
//
// The dApp steps need an http:// origin, which --network none was thought to
// rule out. It does not: loopback survives it, so the page and the stub node
// are served from 127.0.0.1 inside the container and the run reaches nothing
// but this process. See tests/e2e/firefox/dapp.js.
// but this process. See tests/e2e/dapp.js.
//
// LIMITATION, and the difference from the Chrome suite worth knowing: error
// capture here is POLL-BASED, not event-streamed. The console service is
@@ -61,7 +61,7 @@ const {
} = require("ethers");
const { ConsoleErrors, EXTENSION_ORIGIN, start, sleep } = require("./driver");
const { startDappServer } = require("./dapp");
const { startDappServer } = require("../dapp");
const { STUB_COUNTERPARTY } = require("../network");
const { NETWORKS } = require("../../../src/shared/networks");
const {
@@ -394,6 +394,26 @@ async function waitForApprovalWindow(d, timeout = 30000) {
}
}
// Firefox keeps one zoom per site, so a zoom set on the popup's tab holds for
// every page of the extension, the approval windows included.
async function setExtensionZoom(env, zoom) {
const d = env.driver;
await d.switchToWindow(env.popupWindow);
const outcome = await d.executeAsync(
`const done = arguments[arguments.length - 1];
const zoom = arguments[0];
browser.tabs
.getCurrent()
.then((tab) => browser.tabs.setZoom(tab.id, zoom))
.then(() => done({}), (e) => done({ error: String(e) }));`,
[zoom],
);
assert(
outcome && !outcome.error,
"could not set the extension's zoom: " + (outcome && outcome.error),
);
}
function startRequest(d, key, method, params) {
return d.execute(
"window.__dapp.start(arguments[0], arguments[1], arguments[2]);" +
@@ -633,6 +653,14 @@ step(
"personal_sign returns a signature that recovers to the address",
async (env) => {
const d = env.driver;
// The window is opened as wide as the page, and widened once the
// screen is up if it still scrolls sideways
// (https://git.eeqj.de/sneak/AutistMask/issues/518). This suite is
// where that is checked: the Chrome suite's approval windows open at
// 1280x720 whatever width the extension asks for. At 170%, one of
// Firefox's zoom steps, the page is drawn a little larger than the
// 1.7 Firefox reports, so a single widening leaves it a pixel short.
await setExtensionZoom(env, 1.7);
await d.switchToWindow(env.dappWindow);
await startRequest(d, "sign", "personal_sign", [SIGN_HEX, env.address]);
@@ -640,6 +668,12 @@ step(
await d.switchToWindow(popup);
await d.waitVisible("#view-approve-sign");
await d.waitFor(
"the sign prompt to fit its window without scrolling sideways",
"return document.documentElement.scrollWidth <=" +
" document.documentElement.clientWidth;",
);
const screen = await d.execute(
`return {
origin: document.getElementById("approve-sign-origin").textContent,
@@ -674,6 +708,7 @@ step(
await d.switchToWindow(env.dappWindow);
const outcome = await settleRequest(d, "sign");
await setExtensionZoom(env, 1);
assert(
outcome.settled === "resolved",
"personal_sign did not resolve: " + JSON.stringify(outcome),
+1 -1
View File
@@ -94,7 +94,7 @@ function word(value) {
// is the real inpage provider and not a copy the harness wired up.
//
// DAPP_HTML below is exported and served verbatim by the Firefox suite too
// (tests/e2e/firefox/dapp.js), from a loopback origin rather than through a
// (tests/e2e/dapp.js), from a loopback origin rather than through a
// route handler. The two suites drive different browsers over different
// protocols, but the page they drive — the __dapp API, the message log — is
// one fixture, so an assertion written against it means the same thing on
+2 -4
View File
@@ -3861,10 +3861,8 @@ test("eth_requestAccounts approved returns the selected address (#183)", async (
// 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
// does not, and the sign and transaction tests below all require the
// origin to still be authorized.
// Remembered, because the sign and transaction tests below all
// require the origin to still be authorized.
await popup.check("#approve-remember");
await clickAndClose(popup, "#btn-approve");
+549
View File
@@ -0,0 +1,549 @@
// A transaction approval and a signature approval the user leaves open for
// seven minutes, their windows minimized, are still answered, and the site
// receives both results (https://git.eeqj.de/sneak/AutistMask/issues/540).
//
// Both approvals, and the site's requests waiting on them, live only in the
// background worker's memory. With nothing else going on, Chrome stopped that
// worker about six minutes after the site's request, while the window was
// still open, and the user's answer then reached nobody. While an approval is
// pending, the worker now makes an extension call every 20 seconds, which
// keeps it running (addPendingApproval() in src/background/index.js). The
// windows are minimized for the wait because
// Chrome slows a hidden page's timers to once a minute after a few minutes,
// so anything the window itself did to keep the worker running would fail
// here.
//
// A separate program from run.js, and not driven by Playwright: Playwright
// keeps DevTools attached to the background worker, and Chrome never stops a
// worker DevTools is attached to, so in run.js the worker outlives any wait
// and a wait there proves nothing. This program starts Chromium itself and
// speaks the DevTools protocol over the pipe Chromium opens for it
// (--remote-debugging-pipe), attaching to pages only, never to the worker.
// Before the approvals are raised it watches the worker stop on its own, so a
// run in which something else keeps the worker running cannot pass.
//
// The site is connected without "Remember", and once the worker has stopped
// the program requires the site still to be told the address: such a
// connection used to be held in the worker's memory and ended with it
// (https://git.eeqj.de/sneak/AutistMask/issues/526).
//
// The dApp page and the stub node are the loopback ones the Firefox suite also
// uses (tests/e2e/dapp.js). The site is connected and the node chosen
// through the extension's own screens, so nothing is written to its storage
// from outside.
//
// Run through script/test-e2e, which builds the extension and the pinned
// container.
//
// node tests/e2e/slowApproval.js
"use strict";
const fs = require("fs");
const os = require("os");
const path = require("path");
const { spawn } = require("child_process");
const { chromium } = require("playwright-core");
const {
Transaction,
getAddress,
getBytes,
hexlify,
parseEther,
toQuantity,
toUtf8Bytes,
verifyMessage,
} = require("ethers");
const { PASSWORD } = require("./harness");
const { STUB_COUNTERPARTY } = require("./network");
const { startDappServer } = require("./dapp");
const DIST_CHROME = path.resolve(__dirname, "..", "..", "dist", "chrome");
// Chrome stopped the worker six minutes after the site's request when nothing
// kept it running, so the approvals are left open for longer than that.
const APPROVAL_WAIT_MS = 7 * 60 * 1000;
// With nothing pending, Chrome stops the worker 30 seconds after its last
// event; this is how long the program waits to see that.
const IDLE_STOP_TIMEOUT_MS = 90000;
const SIGN_HEX = hexlify(toUtf8Bytes("AutistMask e2e: a slow signature"));
const checks = [];
let failed = 0;
function check(name, cond, detail) {
checks.push(name);
if (cond) {
console.log("ok " + checks.length + " - " + name);
} else {
failed += 1;
console.log("not ok " + checks.length + " - " + name);
if (detail) console.log(" " + detail);
}
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// Poll `probe` until it returns something truthy, and return that.
async function until(what, probe, timeout = 30000) {
const deadline = Date.now() + timeout;
for (;;) {
const value = await probe().catch(() => null);
if (value) return value;
if (Date.now() > deadline) {
throw new Error(
"gave up after " + timeout + "ms waiting for " + what,
);
}
await sleep(250);
}
}
// The DevTools protocol over Chromium's pipe: commands go to its fd 3, and
// replies and events come back on its fd 4, each one JSON message ended by a
// NUL byte. Events are not used and are dropped. Returns send(method, params,
// sessionId), which resolves with the command's result.
function devtools(browser) {
const waiting = new Map();
let nextId = 1;
let pending = "";
const replies = browser.stdio[4];
replies.setEncoding("utf8");
replies.on("data", (chunk) => {
pending += chunk;
let end;
while ((end = pending.indexOf("\0")) !== -1) {
const message = JSON.parse(pending.slice(0, end));
pending = pending.slice(end + 1);
const caller = waiting.get(message.id);
if (!caller) continue;
waiting.delete(message.id);
if (message.error) {
caller.reject(
new Error(caller.method + ": " + message.error.message),
);
} else {
caller.resolve(message.result);
}
}
});
replies.on("close", () => {
for (const caller of waiting.values()) {
caller.reject(new Error("the browser exited"));
}
waiting.clear();
});
return (method, params = {}, sessionId) =>
new Promise((resolve, reject) => {
const id = nextId++;
waiting.set(id, { method, resolve, reject });
browser.stdio[3].write(
JSON.stringify({ id, method, params, sessionId }) + "\0",
);
});
}
// Attach to a page and return a function that evaluates an expression in it,
// awaiting a promise it returns, and resolves with the value.
async function attach(send, targetId) {
const { sessionId } = await send("Target.attachToTarget", {
targetId,
flatten: true,
});
return async (expression) => {
const r = await send(
"Runtime.evaluate",
{ expression, awaitPromise: true, returnByValue: true },
sessionId,
);
if (r.exceptionDetails) {
throw new Error(
"evaluating " +
expression.slice(0, 80) +
" threw: " +
JSON.stringify(r.exceptionDetails.exception || {}),
);
}
return r.result.value;
};
}
// Whether the page has loaded and shows the element with this id: the
// `hidden` class showView() keeps on every other view is on neither it nor
// anything around it.
function shows(id) {
return (
"document.readyState === 'complete' &&" +
" !!document.getElementById(" +
JSON.stringify(id) +
") && !document.getElementById(" +
JSON.stringify(id) +
").closest('.hidden')"
);
}
// Set an input's value and fire the input event typing would fire.
function typeInto(id, value) {
return (
"(() => { const el = document.getElementById(" +
JSON.stringify(id) +
"); el.value = " +
JSON.stringify(value) +
"; el.dispatchEvent(new Event('input', { bubbles: true })); })()"
);
}
function click(id) {
return "document.getElementById(" + JSON.stringify(id) + ").click()";
}
// The outcome of a request the dApp page started with __dapp.start(), or
// {settled: "pending"} if it has not settled within `timeout`.
function settled(key, timeout = 30000) {
return (
"Promise.race([window.__dapp.settle(" +
JSON.stringify(key) +
"), new Promise((r) => setTimeout(() => r({ settled: 'pending' }), " +
timeout +
"))])"
);
}
async function main() {
if (!fs.existsSync(path.join(DIST_CHROME, "manifest.json"))) {
console.error(
"slowApproval: no unpacked build at " +
DIST_CHROME +
" — run make build first",
);
process.exitCode = 1;
return;
}
const node = await startDappServer();
const profile = fs.mkdtempSync(
path.join(os.tmpdir(), "autistmask-slow-approval-"),
);
const browser = spawn(
chromium.executablePath(),
[
"--headless",
// The container runs unprivileged, as in harness.js.
"--no-sandbox",
"--remote-debugging-pipe",
"--user-data-dir=" + profile,
"--disable-extensions-except=" + DIST_CHROME,
"--load-extension=" + DIST_CHROME,
// Nothing but the loopback dApp page and stub node can be reached.
"--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1",
"about:blank",
],
{ stdio: ["ignore", "ignore", "ignore", "pipe", "pipe"] },
);
try {
const send = devtools(browser);
const targets = async () =>
(await send("Target.getTargets")).targetInfos;
const worker = await until("the background worker", async () =>
(await targets()).find(
(t) =>
t.type === "service_worker" &&
t.url.startsWith("chrome-extension://"),
),
);
// The extension id is the host of the worker's URL, as in harness.js.
const popupUrl =
"chrome-extension://" +
new URL(worker.url).host +
"/src/popup/index.html";
const workerRunning = async () =>
(await targets()).some((t) => t.url === worker.url);
const openPage = async (url) => {
const { targetId } = await send("Target.createTarget", { url });
return { targetId, evaluate: await attach(send, targetId) };
};
const approvalPages = async () =>
(await targets()).filter(
(t) => t.type === "page" && t.url.includes("?approval="),
);
// A wallet, through the popup's own screens, and the stub node as its
// RPC endpoint, through Settings.
const popup = await openPage(popupUrl);
await until("the welcome screen", () =>
popup.evaluate(shows("view-welcome")),
);
await popup.evaluate(click("btn-welcome-add"));
await until("the add wallet screen", () =>
popup.evaluate(shows("view-add-wallet")),
);
await popup.evaluate(click("btn-generate-phrase"));
await until("a generated recovery phrase", () =>
popup.evaluate(
"document.getElementById('wallet-mnemonic').value.trim()" +
".split(/\\s+/).length >= 12",
),
);
await popup.evaluate(typeInto("add-wallet-password", PASSWORD));
await popup.evaluate(typeInto("add-wallet-password-confirm", PASSWORD));
await popup.evaluate(click("btn-add-wallet-confirm"));
await until(
"the main screen",
() => popup.evaluate(shows("view-main")),
60000,
);
await popup.evaluate(click("btn-settings"));
await until("the settings screen", () =>
popup.evaluate(shows("view-settings")),
);
await popup.evaluate(typeInto("settings-rpc", node.rpcUrl));
await popup.evaluate(click("btn-save-rpc"));
await until("the stub node saved as the RPC endpoint", () =>
popup.evaluate(
"new Promise((resolve) => chrome.storage.local.get(" +
"'autistmask', (r) => resolve(r.autistmask.rpcUrl === " +
JSON.stringify(node.rpcUrl) +
")))",
),
);
await send("Target.closeTarget", { targetId: popup.targetId });
// The site, connected through the real prompt without "Remember", so
// the connection is one that has to outlive the worker stopping below.
const dapp = await openPage(node.url);
await until("the injected provider", () =>
dapp.evaluate("!!window.ethereum && !!window.__dapp"),
);
await dapp.evaluate(
"window.__dapp.start('accounts', 'eth_requestAccounts', [])",
);
const prompt = await until(
"the connection prompt",
async () => (await approvalPages())[0],
);
const inPrompt = await attach(send, prompt.targetId);
await until("the connection prompt's screen", () =>
inPrompt(shows("view-approve-site")),
);
await inPrompt(
"document.getElementById('approve-remember').checked = false",
);
// The prompt closes itself on the click.
await inPrompt(click("btn-approve")).catch(() => {});
const accounts = await dapp.evaluate(settled("accounts"));
if (accounts.settled !== "resolved") {
throw new Error(
"the site could not be connected: " + JSON.stringify(accounts),
);
}
const address = getAddress(accounts.result[0]);
console.log("# address " + address + ", site " + node.origin);
// The control: with nothing pending, the worker stops on its own.
// Were something else keeping it running, the wait below would
// prove nothing.
const idleStart = Date.now();
const stoppedWhenIdle = await until(
"the idle worker to stop",
async () => !(await workerRunning()),
IDLE_STOP_TIMEOUT_MS,
).catch(() => false);
check(
"with nothing pending, Chrome stops the background worker on its own",
stoppedWhenIdle,
"the worker was still running " +
IDLE_STOP_TIMEOUT_MS / 1000 +
"s after its last event, so something other than the " +
"approval windows is keeping it running",
);
console.log(
"# idle worker stopped after " +
Math.round((Date.now() - idleStart) / 1000) +
"s",
);
// The site's request starts the worker again, and the new worker still
// has the connection allowed without "Remember"
// (https://git.eeqj.de/sneak/AutistMask/issues/526).
const stillConnected = await dapp.evaluate(
"window.ethereum.request({ method: 'eth_accounts' })",
);
check(
"a site allowed without Remember is still connected after the worker stopped",
Array.isArray(stillConnected) &&
stillConnected.length === 1 &&
getAddress(stillConnected[0]) === address,
"eth_accounts answered " + JSON.stringify(stillConnected),
);
// A transaction approval and a signature approval, both open at once.
await dapp.evaluate(
"window.__dapp.start('tx', 'eth_sendTransaction', [" +
JSON.stringify({
from: address,
to: STUB_COUNTERPARTY,
value: toQuantity(parseEther("0.01")),
}) +
"])",
);
await until("the transaction approval window", async () =>
(await approvalPages()).length === 1 ? true : null,
);
await dapp.evaluate(
"window.__dapp.start('sign', 'personal_sign', [" +
JSON.stringify(SIGN_HEX) +
", " +
JSON.stringify(address) +
"])",
);
const windows = await until("both approval windows", async () => {
const pages = await approvalPages();
return pages.length === 2 ? pages : null;
});
let txWindow = null;
let signWindow = null;
const windowIds = [];
for (const page of windows) {
const evaluate = await attach(send, page.targetId);
const view = await until("an approval screen", () =>
evaluate(
"(" +
shows("view-approve-tx") +
") ? 'tx' : (" +
shows("view-approve-sign") +
") ? 'sign' : null",
),
);
if (view === "tx") txWindow = evaluate;
if (view === "sign") signWindow = evaluate;
const { windowId } = await send("Browser.getWindowForTarget", {
targetId: page.targetId,
});
windowIds.push(windowId);
}
if (!txWindow || !signWindow) {
throw new Error("the two windows are not one of each kind");
}
const setWindowState = (windowState) =>
Promise.all(
windowIds.map((windowId) =>
send("Browser.setWindowBounds", {
windowId,
bounds: { windowState },
}),
),
);
// The user minimizes both windows while deciding.
await setWindowState("minimized");
const bothHidden = await until(
"both approval pages hidden",
async () =>
(await txWindow("document.visibilityState")) === "hidden" &&
(await signWindow("document.visibilityState")) === "hidden",
).catch(() => false);
check(
"both approval windows are minimized, and their pages are hidden",
bothHidden,
"a page still reports itself visible, so the wait below would " +
"not show what happens to a minimized approval",
);
// The user takes seven minutes. The worker is checked every two
// seconds, and the wait ends early if Chrome stops it.
const opened = Date.now();
let stoppedAfter = null;
while (Date.now() - opened < APPROVAL_WAIT_MS) {
if (!(await workerRunning())) {
stoppedAfter = Math.round((Date.now() - opened) / 1000);
break;
}
await sleep(2000);
}
check(
"the background worker keeps running while both approvals wait in minimized windows",
stoppedAfter === null,
"Chrome stopped the worker " +
stoppedAfter +
"s after the approvals opened",
);
// Back on screen to be answered.
await setWindowState("normal");
await txWindow(typeInto("approve-tx-password", PASSWORD));
await txWindow(click("btn-approve-tx"));
await signWindow(typeInto("approve-sign-password", PASSWORD));
// The window closes itself once the signature is handed over.
await signWindow(click("btn-approve-sign")).catch(() => {});
const tx = await dapp.evaluate(settled("tx"));
const broadcast = node.broadcast.map((raw) => Transaction.from(raw));
check(
"the transaction approved after seven minutes reaches the site",
tx.settled === "resolved" &&
broadcast.length === 1 &&
tx.result === broadcast[0].hash &&
getAddress(broadcast[0].from) === address,
"the page got " +
JSON.stringify(tx) +
" and the node received " +
broadcast.length +
" transaction(s)",
);
const sign = await dapp.evaluate(settled("sign"));
check(
"the signature approved after seven minutes reaches the site",
sign.settled === "resolved" &&
getAddress(verifyMessage(getBytes(SIGN_HEX), sign.result)) ===
address,
"the page got " + JSON.stringify(sign),
);
} catch (e) {
failed += 1;
console.log("# ERROR: " + (e && e.stack ? e.stack : e));
} finally {
if (browser.exitCode === null && browser.signalCode === null) {
const exited = new Promise((resolve) =>
browser.once("exit", resolve),
);
browser.kill();
await exited;
}
await node.close();
// Chromium's helper processes can still be writing to the profile
// for a moment after the browser process itself has exited.
fs.rmSync(profile, { recursive: true, force: true, maxRetries: 5 });
}
console.log("1.." + checks.length);
if (checks.length === 0) {
console.log("# FAILED: this program asserted nothing");
process.exitCode = 1;
return;
}
console.log(
"# " +
(checks.length - failed) +
"/" +
checks.length +
" checks passed",
);
if (failed > 0) {
console.log("# FAILED");
process.exitCode = 1;
}
}
main().catch((e) => {
console.error("slowApproval: " + (e && e.stack ? e.stack : e));
process.exitCode = 1;
});
+141
View File
@@ -0,0 +1,141 @@
// Adding the first wallet left no address active until the popup was reopened,
// so Send and Receive on Home only said "No active address selected."
// (https://git.eeqj.de/sneak/AutistMask/issues/512). Each test adds a wallet
// through one of the three import tabs and checks which address is active by
// opening Receive from Home in the same popup.
const { HDNodeWallet, Mnemonic, Wallet } = require("ethers");
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
ADDRESS,
} = require("./support/popupBoot");
const PHRASE =
"abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon about";
const PRIVATE_KEY = "0x" + "11".repeat(32);
// The master key of PHRASE, so its first address is the phrase's.
const XPRV = HDNodeWallet.fromSeed(
Mnemonic.fromPhrase(PHRASE).computeSeed(),
).extendedKey;
const PASSWORD = "correct horse battery staple";
const PHRASE_ADDRESS = HDNodeWallet.fromPhrase(PHRASE).address;
// Addresses 1 and 2 of PHRASE used, address 0 not: a scan that answers these
// replaces the wallet's list with one that leaves out its first address.
const account = HDNodeWallet.fromPhrase(PHRASE, "", "m/44'/60'/0'/0");
const used = [1, 2].map((index) => ({
address: account.deriveChild(index).address,
index,
}));
const TABS = [
{
tab: "tab-mnemonic",
field: "wallet-mnemonic",
secret: PHRASE,
address: PHRASE_ADDRESS,
},
{
tab: "tab-privkey",
field: "import-private-key",
secret: PRIVATE_KEY,
address: new Wallet(PRIVATE_KEY).address,
},
{
tab: "tab-xprv",
field: "import-xprv-key",
secret: XPRV,
address: PHRASE_ADDRESS,
},
];
// Fills in the open AddWallet screen and presses "Import".
async function importOn(page, { tab, field, secret }) {
await page.click(tab);
page.node(field).value = secret;
page.node("add-wallet-password").value = PASSWORD;
page.node("add-wallet-password-confirm").value = PASSWORD;
await page.click("btn-add-wallet-confirm");
}
afterEach(() => {
cleanupPopup();
});
describe("the first wallet's first address is active right away", () => {
test.each(TABS)("added from $tab, Receive opens on it", async (entry) => {
const page = await bootPopup(undefined);
expect(page.pageErrors).toEqual([]);
await page.click("btn-welcome-add");
await importOn(page, entry);
expect(page.visibleViews()).toEqual(["main"]);
await page.click("btn-main-receive");
expect(page.visibleViews()).toEqual(["receive"]);
expect(page.node("receive-address-block").dataset.full).toBe(
entry.address,
);
});
});
describe("a scan that leaves out the first address", () => {
// The key tab runs no scan.
test.each(TABS.filter((t) => t.tab !== "tab-privkey"))(
"added from $tab, the scan's first address is active",
async (entry) => {
const page = await bootPopup(undefined);
const { scanForAddresses } = require("../src/shared/balances");
scanForAddresses.mockResolvedValueOnce({
addresses: used,
nextIndex: 3,
});
await page.click("btn-welcome-add");
await importOn(page, entry);
expect(page.storage.read("autistmask").activeAddress).toBe(
used[0].address,
);
expect(globalThis.chrome.runtime.sendMessage).toHaveBeenCalledWith({
type: "AUTISTMASK_ACTIVE_CHANGED",
});
await page.click("btn-main-receive");
expect(page.node("receive-address-block").dataset.full).toBe(
used[0].address,
);
},
);
});
describe("adding a wallet while an address is active", () => {
test.each(TABS)(
"added from $tab, the active address is unchanged",
async (entry) => {
const page = await bootPopup(unversionedValidProfile());
expect(page.pageErrors).toEqual([]);
// The phrase and xprv scans leave out the new wallet's first
// address, which was never active here. The key tab runs no scan.
const { scanForAddresses } = require("../src/shared/balances");
scanForAddresses.mockResolvedValueOnce({
addresses: used,
nextIndex: 3,
});
await page.click("btn-add-wallet-bottom");
await importOn(page, entry);
expect(page.visibleViews()).toEqual(["main"]);
expect(page.storage.read("autistmask").activeAddress).toBe(ADDRESS);
expect(
globalThis.chrome.runtime.sendMessage,
).not.toHaveBeenCalledWith({ type: "AUTISTMASK_ACTIVE_CHANGED" });
await page.click("btn-main-receive");
expect(page.node("receive-address-block").dataset.full).toBe(
ADDRESS,
);
},
);
});
+139
View File
@@ -0,0 +1,139 @@
// A popup that opened with no wallet never started the ten-second refresh, so
// after the first wallet was added Home went on showing the balance it had then
// until the popup was reopened
// (https://git.eeqj.de/sneak/AutistMask/issues/530). Each test boots the real
// popup, adds wallets through AddWallet, and runs the intervals the popup set
// with tick(). The balance stand-in writes what the chain holds onto every
// address, as the real refreshBalances() does.
const { HDNodeWallet, Mnemonic } = require("ethers");
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
ADDRESS,
} = require("./support/popupBoot");
const PHRASE =
"abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon about";
const PRIVATE_KEY = "0x" + "11".repeat(32);
const XPRV = HDNodeWallet.fromSeed(
Mnemonic.fromPhrase(PHRASE).computeSeed(),
).extendedKey;
const PASSWORD = "correct horse battery staple";
const TABS = [
{ tab: "tab-mnemonic", field: "wallet-mnemonic", secret: PHRASE },
{ tab: "tab-privkey", field: "import-private-key", secret: PRIVATE_KEY },
{ tab: "tab-xprv", field: "import-xprv-key", secret: XPRV },
];
// A record this build cannot read: its wallets are a string.
const UNREADABLE = {
hasWallet: true,
wallets: ADDRESS,
activeAddress: ADDRESS,
};
// The ETH balance the chain holds for every address, in ether.
let chainBalance = "0.0";
// Boots the popup over `stored`, with the balance stand-in reading the chain.
async function boot(stored) {
const page = await bootPopup(stored);
const { refreshBalances } = require("../src/shared/balances");
refreshBalances.mockImplementation(async (wallets) => {
for (const wallet of wallets) {
for (const addr of wallet.addresses) addr.balance = chainBalance;
}
});
return { page, refreshBalances };
}
// Fills in the open AddWallet screen and presses "Import".
async function importOn(page, { tab, field, secret }) {
await page.click(tab);
page.node(field).value = secret;
page.node("add-wallet-password").value = PASSWORD;
page.node("add-wallet-password-confirm").value = PASSWORD;
await page.click("btn-add-wallet-confirm");
}
// Fake timers hold the timer of the flash message an import ends with, which
// would otherwise fire after cleanupPopup() has removed the page and throw.
beforeEach(() => {
jest.useFakeTimers();
chainBalance = "0.0";
});
afterEach(() => {
cleanupPopup();
jest.useRealTimers();
});
describe("a popup that opened on Welcome", () => {
test.each(TABS)(
"after the first wallet is added from $tab, funds that arrive show on Home",
async (entry) => {
const { page } = await boot(undefined);
expect(page.pageErrors).toEqual([]);
await page.click("btn-welcome-add");
await importOn(page, entry);
expect(page.visibleViews()).toEqual(["main"]);
expect(page.text("total-value")).toBe("0.0000 ETH");
chainBalance = "2.0";
await page.tick();
expect(page.text("total-value")).toBe("2.0000 ETH");
},
);
test("a second wallet starts no second refresh", async () => {
const { page, refreshBalances } = await boot(undefined);
await page.click("btn-welcome-add");
await importOn(page, TABS[1]);
await page.click("btn-add-wallet-bottom");
await importOn(page, TABS[0]);
const calls = refreshBalances.mock.calls.length;
await page.tick();
expect(refreshBalances).toHaveBeenCalledTimes(calls + 1);
});
test("a wallet added as the recovery screen goes up starts no refresh", async () => {
const { page, refreshBalances } = await boot(undefined);
// The stored record becomes unreadable during the address scan that
// follows the import, and a save finds it.
const { scanForAddresses } = require("../src/shared/balances");
const { saveState } = require("../src/shared/state");
scanForAddresses.mockImplementationOnce(async () => {
page.storage.write("autistmask", UNREADABLE);
await saveState().catch(() => {});
return { addresses: [], nextIndex: 1 };
});
await page.click("btn-welcome-add");
await importOn(page, TABS[0]);
expect(page.visibleViews()).toEqual(["state-recovery"]);
await page.tick();
expect(refreshBalances).not.toHaveBeenCalled();
});
});
describe("a popup that opened with a wallet", () => {
test("adding a wallet starts no second refresh", async () => {
const { page, refreshBalances } = await boot(unversionedValidProfile());
await page.click("btn-add-wallet-bottom");
await importOn(page, TABS[1]);
const calls = refreshBalances.mock.calls.length;
await page.tick();
expect(refreshBalances).toHaveBeenCalledTimes(calls + 1);
});
});
+7
View File
@@ -267,6 +267,13 @@ describe.each([
);
});
test("the warning for a recipient that has never sent a transaction", async () => {
await confirmSend("0.1");
expect(text("confirm-recipient-warning")).toContain(
"has never sent a transaction and holds no " + symbol + ".",
);
});
// A token reports whatever symbol it likes. One reporting the label the
// wallet shows its native token under, on this network or any other, is
// a fake, exactly as one reporting `ETH` always was.
+282
View File
@@ -0,0 +1,282 @@
// What a page is told when a request fails
// (https://git.eeqj.de/sneak/AutistMask/issues/533).
//
// EIP-1193 gives every rejected request a numeric `code`, and lets it carry
// `data`. A node's error used to reach the page as its message alone: a
// reverting eth_call lost code 3 and the revert data a site needs to decode
// the revert, and a site could not tell that failure from any other.
//
// Each request here goes from a page through the real background to a fake
// node, and what is checked is the answer the background gives the page. The
// content script relays that answer as it is, and src/content/inpage.js turns
// its code and data into the error the page catches (tests/inpageErrors.test.js).
//
// The background reaches a node two ways, and the fake node answers both:
// fetch(), which the read-only methods are proxied through, and ethers' own
// HTTP transport, which a transaction is prepared through.
const { AbiCoder } = require("ethers");
const { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub");
const ORIGIN = "https://dapp.example";
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const TOKEN = "0x1111111111111111111111111111111111111111";
const SEPOLIA = networkById("sepolia");
// Error("bal"), which a token whose transfer() does require(..., "bal")
// reverts with when the balance is too low.
const REVERT_DATA =
"0x08c379a0" +
AbiCoder.defaultAbiCoder().encode(["string"], ["bal"]).slice(2);
// What a node answers for a call that reverts, and for an address that is not
// one.
const REVERTED = {
code: 3,
message: "execution reverted: bal",
data: REVERT_DATA,
};
const INVALID_ADDRESS = { code: -32602, message: "invalid string length" };
const WITH_ADDRESS = {
hasWallet: true,
wallets: [
{
name: "Wallet 1",
type: "hd",
xpub: "xpub-1",
addresses: [
{ address: ADDRESS, balance: "0.0", tokenBalances: [] },
],
},
],
activeAddress: ADDRESS,
networkId: "sepolia",
rpcUrl: SEPOLIA.defaultRpcUrl,
blockscoutUrl: SEPOLIA.defaultBlockscoutUrl,
allowedSites: { [ADDRESS]: [ORIGIN] },
deniedSites: {},
trackedTokens: [],
};
const NO_ADDRESS = {
...WITH_ADDRESS,
hasWallet: false,
wallets: [],
activeAddress: null,
allowedSites: {},
};
const realFetch = globalThis.fetch;
afterEach(() => {
delete global.chrome;
globalThis.fetch = realFetch;
});
// The background, over a stored profile, with a node that answers each
// JSON-RPC method from `node`: a method's entry is the result, or
// `{ error }` for the error the node answers with. A method not in `node`
// is answered "method not found", so a test that reaches one fails on it.
// `node` null is a node that cannot be reached at all.
function loadBackground(profile, node) {
jest.resetModules();
jest.doMock("../src/shared/phishingDomains", () => ({
isPhishingDomain: () => false,
}));
jest.doMock("../src/shared/alarms", () => ({
BALANCE_REFRESH_ALARM: "balance",
BALANCE_REFRESH_PERIOD_MINUTES: 1,
ensureRecurringAlarms: jest.fn(async () => {}),
registerAlarmHandlers: jest.fn(),
}));
function reply({ id, method }) {
const entry = node[method];
if (entry === undefined) {
return {
jsonrpc: "2.0",
id,
error: { code: -32601, message: "method not found: " + method },
};
}
if (entry && entry.error)
return { jsonrpc: "2.0", id, error: entry.error };
return { jsonrpc: "2.0", id, result: entry };
}
globalThis.fetch = jest.fn(async (url, opts) => {
if (!node) throw new TypeError("fetch failed");
return { status: 200, json: async () => reply(JSON.parse(opts.body)) };
});
// The ethers the background is about to load: jest.resetModules() above
// means it is a fresh copy, not the one this file required at the top.
require("ethers").FetchRequest.registerGetUrl(async (req) => {
if (!node) throw new Error("connect ECONNREFUSED");
const body = JSON.parse(Buffer.from(req.body).toString("utf8"));
const answer = Array.isArray(body) ? body.map(reply) : reply(body);
return {
statusCode: 200,
statusMessage: "OK",
headers: { "content-type": "application/json" },
body: Buffer.from(JSON.stringify(answer)),
};
});
let messageListener = null;
global.chrome = {
storage: makeStorageStub({ autistmask: profile }),
runtime: {
getURL: (path) => "chrome-extension://autistmask/" + path,
onMessage: {
addListener: (fn) => {
messageListener = fn;
},
},
onConnect: { addListener: () => {} },
lastError: null,
},
windows: { onRemoved: { addListener: () => {} } },
action: { setPopup: () => {} },
};
require("../src/background/index");
// The answer the page gets to one request.
return function request(method, params) {
return new Promise((resolve) => {
messageListener(
{ type: "AUTISTMASK_RPC", method, params },
{ origin: ORIGIN },
resolve,
);
});
};
}
const TRANSFER = {
from: ADDRESS,
to: TOKEN,
// transfer(ADDRESS, 1)
data:
"0xa9059cbb" +
AbiCoder.defaultAbiCoder()
.encode(["address", "uint256"], [ADDRESS, 1])
.slice(2),
};
describe("a node's error reaches the page with the node's code and data", () => {
test("a reverting eth_call", async () => {
const request = loadBackground(WITH_ADDRESS, {
eth_call: { error: REVERTED },
});
const answer = await request("eth_call", [TRANSFER, "latest"]);
expect(answer).toEqual({ error: REVERTED });
});
test("an eth_getBalance for an address that is not one", async () => {
const request = loadBackground(WITH_ADDRESS, {
eth_getBalance: { error: INVALID_ADDRESS },
});
const answer = await request("eth_getBalance", ["0x1234", "latest"]);
expect(answer).toEqual({ error: INVALID_ADDRESS });
});
// The wallet's own sentence, with the node's code and revert data, so the
// site can decode why its transaction would revert.
test("an eth_sendTransaction whose gas estimate reverts", async () => {
const request = loadBackground(WITH_ADDRESS, {
eth_getTransactionCount: "0x7",
eth_estimateGas: { error: REVERTED },
});
const answer = await request("eth_sendTransaction", [TRANSFER]);
expect(answer).toEqual({
error: {
code: REVERTED.code,
message: expect.stringMatching(
/^The transaction could not be prepared: execution reverted/,
),
data: REVERT_DATA,
},
});
});
});
// One request for each way a request is refused without the node's code: by
// the wallet itself, or because the node could not be reached.
describe("no rejected request reaches the page without a numeric code", () => {
test.each([
[
"eth_requestAccounts with no address",
NO_ADDRESS,
{},
"eth_requestAccounts",
[],
4100,
],
[
"personal_sign with no address",
NO_ADDRESS,
{},
"personal_sign",
["0x00", ADDRESS],
4100,
],
[
"eth_signTypedData_v4 with no address",
NO_ADDRESS,
{},
"eth_signTypedData_v4",
[ADDRESS, "{}"],
4100,
],
[
"eth_sendTransaction with no address",
NO_ADDRESS,
{},
"eth_sendTransaction",
[TRANSFER],
4100,
],
[
"eth_sendTransaction with two different gas limits",
WITH_ADDRESS,
{},
"eth_sendTransaction",
[{ ...TRANSFER, gas: "0x5208", gasLimit: "0x5209" }],
-32602,
],
[
"eth_call to a node that cannot be reached",
WITH_ADDRESS,
null,
"eth_call",
[TRANSFER, "latest"],
-32603,
],
[
"eth_sendTransaction to a node that cannot be reached",
WITH_ADDRESS,
null,
"eth_sendTransaction",
[TRANSFER],
-32603,
],
])("%s", async (_name, profile, node, method, params, code) => {
const request = loadBackground(profile, node);
const answer = await request(method, params);
expect(answer.error.code).toBe(code);
expect(answer.error.message).toEqual(expect.any(String));
});
});
+1 -1
View File
@@ -74,7 +74,7 @@ test("ethers puts the URL in the error message, not in the short message", async
test("the recipient checks before a send", async () => {
await getFullWarnings(ADDRESS, getProvider(RPC_URL, "mainnet"));
expectFailureLoggedWithoutKey("contract check failed");
expectFailureLoggedWithoutKey("tx count check failed");
expectFailureLoggedWithoutKey("tx count or balance check failed");
});
test("the ENS reverse lookup", async () => {
+47 -2
View File
@@ -8,8 +8,15 @@
// and a request the browser said nothing about was credited to whatever the
// page wrote (https://git.eeqj.de/sneak/AutistMask/issues/407).
//
// Every sender here lacks sender.origin, as on old Firefox. The connection
// check on eth_accounts is what shows which site a request was credited to.
// An origin with no host names no site, so its request is refused
// (https://git.eeqj.de/sneak/AutistMask/issues/527). The browser reports
// "null" for a page with an opaque origin, such as one its server sandboxes
// with a Content-Security-Policy, and Chrome reports "file://" for a local
// file.
//
// Every other sender here lacks sender.origin, as on old Firefox. The
// connection check on eth_accounts is what shows which site a request was
// credited to.
const { makeStorageStub } = require("./support/storageStub");
@@ -144,3 +151,41 @@ describe("a request the browser does not say the sender of", () => {
});
});
});
describe("a request whose origin has no host", () => {
test("from a page with an opaque origin is refused, whatever its URL says", async () => {
const accounts = loadBackground();
const result = await accounts({
origin: "null",
url: CONNECTED_ORIGIN + "/sandboxed.html",
tab: { url: CONNECTED_ORIGIN + "/sandboxed.html" },
});
expect(result).toEqual({
error: {
code: 4100,
message:
"The wallet could not tell which site sent this request.",
},
});
});
test("from a local file is refused", async () => {
const accounts = loadBackground();
const result = await accounts({
origin: "file://",
url: "file:///home/user/page.html",
tab: { url: "file:///home/user/page.html" },
});
expect(result).toEqual({
error: {
code: 4100,
message:
"The wallet could not tell which site sent this request.",
},
});
});
});
+213
View File
@@ -0,0 +1,213 @@
// The Send screen refuses every amount the confirmation screen would refuse,
// by the same check (https://git.eeqj.de/sneak/AutistMask/issues/532).
//
// Send used to accept anything parseFloat() read as above zero, so "1e-3",
// "1abc" and an amount with more decimal places than the token has went on to
// a confirmation screen that then refused them. Both screens now call
// isValidAmount() in src/shared/txValidation.js.
//
// Driven through the real Send review handler and the real confirmation
// screen, with only the node and the DOM stubbed.
"use strict";
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
debugFetch: jest.fn(),
setRuntimeDebug: () => {},
isDebug: () => false,
}));
// What the confirmation screen asks the node for.
const mockProvider = {
getFeeData: async () => ({
maxFeePerGas: 2000000000n,
gasPrice: 1000000000n,
}),
estimateGas: async () => 21000n,
getCode: async () => "0x",
getTransactionCount: async () => 1,
getBalance: async () => 0n,
};
jest.mock("../src/shared/balances", () => {
const actual = jest.requireActual("../src/shared/balances");
return { ...actual, getProvider: () => mockProvider };
});
// The confirmation screen's Etherscan label lookup fails, as it does offline.
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);
},
appendChild(child) {
return child;
},
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 { state } = require("../src/shared/state");
const send = require("../src/popup/views/send");
const confirmTx = require("../src/popup/views/confirmTx");
const HOLDER = "0x" + "a".repeat(40);
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
// A token with 6 decimal places, as USDC has.
const TOKEN = "0x" + "d".repeat(40);
const TOKEN_BY_SYMBOL = { ETH: "ETH", TOK: TOKEN };
function el(id) {
return global.document.getElementById(id);
}
// Press Review on the Send screen with `amount` entered, for a send of `token`
// ("ETH" or TOKEN), and return what it handed the confirmation screen, or null
// when it handed nothing over.
async function review(amount, token) {
let handed = null;
send.init({ showConfirmTx: (info) => (handed = info) });
state.selectedToken = token;
el("send-to").value = RECIPIENT;
el("send-amount").value = amount;
await el("btn-send-review").handlers.get("click")();
return handed;
}
// Show the confirmation screen for the send Review hands over for 0.5, with
// `amount` in its place, and let its fee estimate settle.
async function confirm(amount, token) {
const txInfo = await review("0.5", token);
confirmTx.show({ ...txInfo, amount });
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
beforeEach(() => {
elements.clear();
state.wallets = [
{
name: "Wallet 1",
addresses: [
{
address: HOLDER,
balance: "1.0",
tokenBalances: [
{
address: TOKEN,
name: "Token",
symbol: "TOK",
decimals: 6,
balance: "250.0",
holders: 50000,
},
],
},
],
},
];
state.selectedWallet = 0;
state.selectedAddress = 0;
state.trackedTokens = [
{ address: TOKEN, symbol: "TOK", name: "Token", decimals: 6 },
];
state.fraudContracts = [];
state.selectedToken = null;
state.currentView = null;
});
describe.each([
["1e-3", "ETH"],
["1e-3", "TOK"],
["1abc", "ETH"],
// 19 decimal places; ETH has 18.
["0.0000000000000000001", "ETH"],
// 7 decimal places; the token has 6.
["0.0000001", "TOK"],
["0", "ETH"],
["-1", "ETH"],
])("%s %s", (amount, symbol) => {
const token = TOKEN_BY_SYMBOL[symbol];
test("Send refuses it with a flash message and opens nothing", async () => {
expect(await review(amount, token)).toBe(null);
expect(el("flash-msg").textContent).toBe(
"Please enter a valid amount.",
);
});
test("the confirmation screen refuses it too", async () => {
await confirm(amount, token);
expect(el("confirm-errors").innerHTML).toContain(
"Please enter a valid amount to send.",
);
expect(el("btn-confirm-send").disabled).toBe(true);
});
});
describe.each([
["0.5", "ETH"],
["0.5", "TOK"],
// Exactly the 6 decimal places the token has.
["0.000001", "TOK"],
// Only zeros past the token's 6 places, and past ETH's 18.
["1.0000000", "TOK"],
["0.5000000000000000000", "ETH"],
])("%s %s", (amount, symbol) => {
const token = TOKEN_BY_SYMBOL[symbol];
test("Send hands it to the confirmation screen", async () => {
expect((await review(amount, token)).amount).toBe(amount);
expect(el("flash-msg").textContent).toBe("");
});
test("the confirmation screen lets it be sent", async () => {
await confirm(amount, token);
expect(el("confirm-errors").innerHTML).toBe("");
expect(el("btn-confirm-send").disabled).toBe(false);
});
});
+28 -2
View File
@@ -242,6 +242,13 @@ async function settle() {
* "?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.
* @param {object} [options.windows] chrome.windows, for a test of the approval
* window resizing itself.
* @param {object} [options.tabs] chrome.tabs, for the same test: the page's
* tab and its zoom.
* @param {object} [options.documentElement] members of document.documentElement
* that replace the stub's own: scrollWidth and clientWidth, which are equal
* unless a test says otherwise, so the page does not scroll sideways.
* @returns {Promise<object>} handles onto the booted page.
*/
async function bootPopup(stored, options) {
@@ -258,12 +265,17 @@ async function bootPopup(stored, options) {
formatAddressTotal: () => "",
getAddressValue: () => ({ usd: null, partial: false }),
}));
// scanForAddresses() answers in the real one's shape, a scan that found
// no used address: AddWallet reads `addresses` from it.
jest.doMock("../../src/shared/balances", () => ({
fetchTokenBalances: jest.fn(async () => []),
refreshBalances: jest.fn(async () => {}),
lookupTokenInfo: jest.fn(async () => null),
getProvider: () => ({}),
scanForAddresses: jest.fn(async () => []),
scanForAddresses: jest.fn(async () => ({
addresses: [],
nextIndex: 1,
})),
}));
// filterTransactions() answers in the real one's shape: Home,
// AddressDetail and AddressToken read both fields, and a bare list makes
@@ -277,7 +289,13 @@ async function bootPopup(stored, options) {
(options && options.storage) ||
makeStorageStub(stored === undefined ? {} : { autistmask: stored });
const document = makeDocument(POPUP_HTML);
Object.assign(
document.documentElement,
{ scrollWidth: 396, clientWidth: 396 },
options && options.documentElement,
);
const reloads = [];
const windowListeners = {};
globalThis.chrome = {
storage: { local: storage.local },
@@ -287,6 +305,8 @@ async function bootPopup(stored, options) {
onMessage: { addListener: () => {} },
...(options && options.runtime),
},
windows: options && options.windows,
tabs: options && options.tabs,
};
globalThis.document = document;
globalThis.window = {
@@ -300,7 +320,13 @@ async function bootPopup(stored, options) {
addEventListener: () => {},
removeEventListener: () => {},
}),
addEventListener: () => {},
// Recorded, as the document's are, so a test that widens the approval
// window can call the page's resize listeners.
listeners: windowListeners,
addEventListener: (name, fn) => {
windowListeners[name] = windowListeners[name] || [];
windowListeners[name].push(fn);
},
};
// The ten-second refresh init() starts, and a transaction wait's timers,
// would outlive the test. So every interval is recorded rather than
+23 -15
View File
@@ -1,4 +1,4 @@
// A chrome.storage.local stub that behaves like the real one.
// A chrome.storage stub that behaves like the real one.
//
// The real extension storage API is a serialization boundary: `set` writes a
// structured clone of what it is given, and `get` hands back a structured
@@ -17,13 +17,8 @@
// So every test that drives real persistence uses this, and nothing rebuilds
// a storage stub by hand.
// `initial` is the starting contents, keyed as storage is: { autistmask: {...} }.
// `onOp` runs before each operation, for a test that needs to advance a clock
// or count round trips.
function makeStorageStub(initial, onOp) {
const store = initial ? structuredClone(initial) : {};
const tick = onOp || (() => {});
// One storage area: get, set and remove over its own store, cloning both ways.
function makeArea(store, tick) {
const get = jest.fn(async (key) => {
tick();
if (key === undefined || key === null) return structuredClone(store);
@@ -49,15 +44,28 @@ function makeStorageStub(initial, onOp) {
for (const k of Array.isArray(key) ? key : [key]) delete store[k];
});
return { get, set, remove };
}
// `initial` is the starting contents of `storage.local`, keyed as storage is:
// { autistmask: {...} }. `storage.session` starts empty.
// `onOp` runs before each operation on either area, for a test that needs to
// advance a clock or count round trips.
function makeStorageStub(initial, onOp) {
const store = initial ? structuredClone(initial) : {};
const tick = onOp || (() => {});
const local = makeArea(store, tick);
return {
// Drop this straight in as chrome.storage.
local: { get, set, remove },
get,
set,
remove,
// What is stored, cloned on the way out: a test can neither observe a
// later write through an object it read nor reach into the store by
// mutating one.
local,
session: makeArea({}, tick),
get: local.get,
set: local.set,
remove: local.remove,
// What is stored in `storage.local`, cloned on the way out: a test can
// neither observe a later write through an object it read nor reach
// into the store by mutating one.
read: (key) =>
key === undefined
? structuredClone(store)
+342
View File
@@ -0,0 +1,342 @@
// The amount the wait and error screens show for a swap whose amount the
// decoder did not read (https://git.eeqj.de/sneak/AutistMask/issues/358).
//
// A V4 swap action not in the deployed shape is not read, and a V4 exact-out
// action is read for its currencies only, so the approval screen has no
// Amount line for either. The wait and error screens then showed the
// transaction's ETH value as the amount, in the swap's input token when the
// calldata named one ("0.0000 WETH" for a swap of 1 WETH) and in ETH when it
// did not ("0.0000 ETH"). They now say the amount could not be decoded. A swap
// paid in native ETH still shows the transaction's ETH value there: that value
// is the ETH the swap pays.
//
// Driven through the real approval screen, password and Confirm included,
// against a minimal DOM stub in the shape tests/approvalNonce.test.js uses.
// The vault is mocked so that no password has to be hashed.
jest.mock("../src/shared/vault", () => ({
decryptWithPassword: jest.fn(),
}));
// The wait screen polls for a receipt, which is there once mockReceipt is set.
let mockReceipt = null;
jest.mock("../src/shared/balances", () => ({
getProvider: () => ({ getTransactionReceipt: async () => mockReceipt }),
refreshBalances: () => {},
}));
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { AbiCoder, Interface } = require("ethers");
const { state } = require("../src/shared/state");
const { decryptWithPassword } = require("../src/shared/vault");
const approval = require("../src/popup/views/approval");
const txStatus = require("../src/popup/views/txStatus");
// A well-known test phrase, and its first address.
const PHRASE = "test test test test test test test test test test test junk";
const FROM = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
const TX_HASH = "0x" + "4".repeat(64);
const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
const ZERO = "0x0000000000000000000000000000000000000000";
// Both in the bundled list: WETH at 18 decimals, USDC at 6.
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const ONE_WETH = 1000000000000000000n;
const coder = AbiCoder.defaultAbiCoder();
const routerIface = new Interface([
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
]);
// V4 action ids and their parameters, each swapping WETH for USDC.
const POOL_KEY = "tuple(address,address,uint24,int24,address)";
const WETH_USDC_POOL = [WETH, USDC, 100, 1, ZERO];
const SWAP_EXACT_IN_SINGLE = 0x06;
const SWAP_EXACT_OUT_SINGLE = 0x08;
const SETTLE = 0x0b;
const SETTLE_ALL = 0x0c;
const TAKE = 0x0e;
const TAKE_ALL = 0x0f;
// The struct V4Router has deployed on mainnet and Sepolia.
const exactInSingle = coder.encode(
[`tuple(${POOL_KEY},bool,uint128,uint128,bytes)`],
[[WETH_USDC_POOL, true, ONE_WETH, 990000n, "0x"]],
);
// The one v4-periphery `main` has moved to, with a `minHopPriceX36` field.
const newerExactInSingle = coder.encode(
[`tuple(${POOL_KEY},bool,uint128,uint128,uint256,bytes)`],
[[WETH_USDC_POOL, true, ONE_WETH, 990000n, 0n, "0x"]],
);
// Deployed shape: buys 990000 base units of USDC for at most 1 WETH.
const exactOutSingle = coder.encode(
[`tuple(${POOL_KEY},bool,uint128,uint128,bytes)`],
[[WETH_USDC_POOL, true, 990000n, ONE_WETH, "0x"]],
);
const settleWeth = coder.encode(
["address", "uint256", "bool"],
[WETH, 0n, true],
);
const takeUsdc = coder.encode(
["address", "address", "uint256"],
[USDC, FROM, 0n],
);
const settleAllWeth = coder.encode(["address", "uint256"], [WETH, ONE_WETH]);
const takeAllUsdc = coder.encode(["address", "uint256"], [USDC, 990000n]);
// The same swaps paid in native ETH, which V4 names as the zero address.
const ETH_USDC_POOL = [ZERO, USDC, 500, 10, ZERO];
const ONE_ETH = 1000000000000000000n;
const ethExactOutSingle = coder.encode(
[`tuple(${POOL_KEY},bool,uint128,uint128,bytes)`],
[[ETH_USDC_POOL, true, 990000n, ONE_ETH, "0x"]],
);
const newerEthExactInSingle = coder.encode(
[`tuple(${POOL_KEY},bool,uint128,uint128,uint256,bytes)`],
[[ETH_USDC_POOL, true, ONE_ETH, 990000n, 0n, "0x"]],
);
// The router pays it from the ETH the transaction sends.
const settleEth = coder.encode(
["address", "uint256", "bool"],
[ZERO, 0n, false],
);
// execute() with one V4_SWAP step of these actions, given as [id, params].
function v4SwapData(actions) {
const input = coder.encode(
["bytes", "bytes[]"],
[new Uint8Array(actions.map((a) => a[0])), actions.map((a) => a[1])],
);
return routerIface.encodeFunctionData("execute", [
"0x10",
[input],
9999999999n,
]);
}
function makeElement(id) {
const classes = new Set();
const el = {
id,
textContent: "",
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"),
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
function click(id) {
return Promise.all((node(id).listeners.click || []).map((fn) => fn()));
}
// Open the approval screen for this calldata, sent to the router with `value`
// wei (no ETH unless given), and confirm it. The background answers the signed
// transaction with `response`.
async function approveSwap(data, response, value = 0n) {
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" }, close: () => {} };
globalThis.chrome.runtime = {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
if (!reply) return;
if (msg.type !== "AUTISTMASK_GET_APPROVAL") return reply(response);
reply({
type: "tx",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: FROM,
approvedTx: {
type: 2,
from: FROM,
chainId: "0x1",
nonce: "0x7",
gasLimit: "0x30d40",
maxPriorityFeePerGas: "0x3b9aca00",
maxFeePerGas: "0x77359400",
to: ROUTER,
value: "0x" + value.toString(16),
data,
accessList: [],
},
});
},
};
state.activeAddress = FROM;
state.trackedTokens = [];
state.wallets = [
{
type: "hd",
name: "Wallet 1",
xpub: "xpub-wallet-1",
encryptedSecret: "encrypted-secret-1",
nextIndex: 1,
addresses: [
{ address: FROM, index: 0, balance: "0", tokenBalances: [] },
],
},
];
txStatus.init({ doRefreshAndRender: () => {} });
approval.init({});
await approval.show(1);
decryptWithPassword.mockResolvedValue(PHRASE);
node("approve-tx-password").value = "any password";
await click("btn-approve-tx");
}
afterEach(() => {
txStatus.endWait();
mockReceipt = null;
});
describe.each([
[
"a swap action in the deployed shape",
[
[SWAP_EXACT_IN_SINGLE, exactInSingle],
[SETTLE, settleWeth],
[TAKE, takeUsdc],
],
"1.0000 WETH",
],
[
"a swap action in the newer shape, with SETTLE naming WETH",
[
[SWAP_EXACT_IN_SINGLE, newerExactInSingle],
[SETTLE, settleWeth],
[TAKE, takeUsdc],
],
"Unknown (could not be decoded)",
],
[
"a swap action in the newer shape, with SETTLE_ALL and TAKE_ALL",
[
[SWAP_EXACT_IN_SINGLE, newerExactInSingle],
[SETTLE_ALL, settleAllWeth],
[TAKE_ALL, takeAllUsdc],
],
"Unknown (could not be decoded)",
],
[
"an exact-out swap action, which is read for its currencies only",
[
[SWAP_EXACT_OUT_SINGLE, exactOutSingle],
[SETTLE, settleWeth],
[TAKE, takeUsdc],
],
"Unknown (could not be decoded)",
],
])("%s", (_name, actions, expected) => {
test("the wait screen's amount", async () => {
await approveSwap(v4SwapData(actions), { txHash: TX_HASH });
expect(node("wait-tx-summary").textContent).toBe(expected);
});
test("the error screen's amount", async () => {
await approveSwap(v4SwapData(actions), {
error: "The transaction could not be sent.",
retryable: false,
});
expect(node("error-tx-summary").textContent).toBe(expected);
});
// The success screen shows the approval screen's decoded lines in place
// of the amount, so the ETH value is not on it either.
test("the success screen has no amount line of its own", async () => {
await approveSwap(v4SwapData(actions), { txHash: TX_HASH });
mockReceipt = { blockNumber: 1 };
// A later popup resumes the wait, and the receipt is there.
txStatus.restoreWait();
for (let i = 0; i < 10; i++) {
await new Promise((r) => setTimeout(r, 0));
}
const summary = node("success-tx-summary").parentElement;
expect(summary.classList.contains("hidden")).toBe(true);
expect(node("success-tx-decoded").innerHTML).not.toContain("0.0000");
});
});
// A swap paid in native ETH has a Token In of "ETH (native)" and no Amount
// line, and sends 1 ETH.
describe.each([
[
"an exact-out swap action paid in ETH, in the deployed shape",
[
[SWAP_EXACT_OUT_SINGLE, ethExactOutSingle],
[SETTLE, settleEth],
[TAKE, takeUsdc],
],
],
[
"a swap action in the newer shape paid in ETH, with SETTLE naming ETH",
[
[SWAP_EXACT_IN_SINGLE, newerEthExactInSingle],
[SETTLE, settleEth],
[TAKE, takeUsdc],
],
],
])("%s", (_name, actions) => {
test("the wait screen shows the ETH the transaction sends", async () => {
await approveSwap(v4SwapData(actions), { txHash: TX_HASH }, ONE_ETH);
expect(node("wait-tx-summary").textContent).toBe("1.0000 ETH");
});
test("the error screen shows the ETH the transaction sends", async () => {
await approveSwap(
v4SwapData(actions),
{
error: "The transaction could not be sent.",
retryable: false,
},
ONE_ETH,
);
expect(node("error-tx-summary").textContent).toBe("1.0000 ETH");
});
});
+6
View File
@@ -116,6 +116,12 @@ describe("transferAmountUnits", () => {
expect(() => transferAmountUnits("0.0000001", 6, 6n)).toThrow();
});
test("encodes zeros past the token's scale as the amount typed", () => {
// The Send screen accepts "1.0000000" for a 6-place token, so the
// transfer must move exactly 1 of it.
expect(transferAmountUnits("1.0000000", 6, 6n)).toBe(1000000n);
});
test("every refusal is a full sentence", () => {
const messages = [];
for (const args of [
+65
View File
@@ -6,6 +6,7 @@ const {
FEE_UNAVAILABLE,
feeReserveWei,
feeEstimateWei,
isValidAmount,
maxEthAmount,
maxTokenAmount,
toFixedPoint,
@@ -33,6 +34,49 @@ describe("toFixedPoint", () => {
});
});
// The one amount check the Send screen and the confirmation screen share.
// The screens themselves are driven in tests/sendAmountRule.test.js.
describe("isValidAmount", () => {
test("accepts a number above zero written as digits and a decimal point", () => {
expect(isValidAmount("0.5")).toBe(true);
expect(isValidAmount("12")).toBe(true);
expect(isValidAmount(".5")).toBe(true);
expect(isValidAmount("0.000000000000000001")).toBe(true);
});
test("refuses an exponent, trailing text, zero and a negative amount", () => {
expect(isValidAmount("1e-3")).toBe(false);
expect(isValidAmount("1abc")).toBe(false);
expect(isValidAmount("0")).toBe(false);
expect(isValidAmount("0.0")).toBe(false);
expect(isValidAmount("-1")).toBe(false);
expect(isValidAmount("")).toBe(false);
expect(isValidAmount(undefined)).toBe(false);
});
test("refuses a digit other than 0 past the token's places", () => {
expect(isValidAmount("0.000001", 6)).toBe(true);
expect(isValidAmount("0.0000001", 6)).toBe(false);
expect(isValidAmount("5", 0)).toBe(true);
expect(isValidAmount("0.5", 0)).toBe(false);
});
test("accepts zeros past the token's places, which leave the amount unchanged", () => {
expect(isValidAmount("1.0000000", 6)).toBe(true);
expect(isValidAmount("5.0", 0)).toBe(true);
expect(isValidAmount("1.0000000000000000000")).toBe(true);
expect(isValidAmount("0.0000000", 6)).toBe(false);
});
test("holds ETH, a token of unknown places, and any token to 18 places", () => {
const nineteen = "0.0000000000000000001";
expect(isValidAmount(nineteen)).toBe(false);
expect(isValidAmount(nineteen, null)).toBe(false);
expect(isValidAmount(nineteen, 24)).toBe(false);
expect(isValidAmount("0.000000000000000001", 24)).toBe(true);
});
});
describe("validateTransfer, native ETH", () => {
const eth = (over) => ({
isErc20: false,
@@ -134,6 +178,17 @@ describe("validateTransfer, native ETH", () => {
).toEqual({ canSend: false, codes: [CODES.AMOUNT_INVALID] });
});
test("rejects a zero amount and an exponent", () => {
expect(validateTransfer(eth({ amount: "0" }))).toEqual({
canSend: false,
codes: [CODES.AMOUNT_INVALID],
});
expect(validateTransfer(eth({ amount: "1e-3" }))).toEqual({
canSend: false,
codes: [CODES.AMOUNT_INVALID],
});
});
test("treats a missing balance as zero, not as unlimited", () => {
const r = validateTransfer(eth({ ethBalance: undefined }));
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH]);
@@ -205,6 +260,16 @@ describe("validateTransfer, ERC-20", () => {
expect(r).toEqual({ canSend: false, codes: [CODES.AMOUNT_INVALID] });
});
test("rejects more decimal places than the token has", () => {
const r = validateTransfer(
erc20({ amount: "0.0000001", tokenDecimals: 6 }),
);
expect(r).toEqual({ canSend: false, codes: [CODES.AMOUNT_INVALID] });
expect(
validateTransfer(erc20({ amount: "0.000001", tokenDecimals: 6 })),
).toEqual({ canSend: true, codes: [] });
});
test("treats a missing token balance as zero", () => {
const r = validateTransfer(erc20({ tokenBalance: undefined }));
expect(r.codes).toEqual([CODES.INSUFFICIENT_TOKEN]);
+105 -1
View File
@@ -28,7 +28,7 @@ globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { getAddress, MaxUint256 } = require("ethers");
const { getAddress, MaxUint256, TypedDataEncoder } = require("ethers");
const { state } = require("../src/shared/state");
const { decryptWithPassword } = require("../src/shared/vault");
const approval = require("../src/popup/views/approval");
@@ -662,3 +662,107 @@ describe("the sign screen refuses typed data for another chain", () => {
expect(node("btn-approve-sign").disabled).toBe(false);
});
});
// ethers signs a `bool` field by whether its value is truthy in JavaScript:
// the string "false" is signed as true, and a field left out as false. The
// screen would show one value while another is signed, so such typed data is
// refused, and so is any declared field left out of the message
// (https://git.eeqj.de/sneak/AutistMask/issues/552). DAI's older permit
// declares the `bool` field `allowed`.
describe("the sign screen refuses a message ethers would sign differently", () => {
const NOT_A_BOOL =
'The field "allowed" is not true or false, so this typed data cannot be signed.';
const MISSING =
'The field "allowed" is missing from the message, so this typed data cannot be signed.';
function daiPermit(allowed) {
return { ...DAI_PERMIT, message: { ...DAI_PERMIT.message, allowed } };
}
const withoutAllowed = {
...DAI_PERMIT,
message: { ...DAI_PERMIT.message },
};
delete withoutAllowed.message.allowed;
beforeEach(() => {
decryptWithPassword.mockClear();
});
test('ethers signs "false" as true, and a bool left out as false', () => {
const hash = (data) =>
TypedDataEncoder.hash(
data.domain,
{ Permit: data.types.Permit },
data.message,
);
expect(hash(daiPermit("false"))).toBe(hash(daiPermit(true)));
expect(hash(withoutAllowed)).toBe(hash(daiPermit(false)));
});
test('a bool sent as the string "false" shows the error, with Sign disabled', async () => {
await openSignScreen(daiPermit("false"));
expect(node("approve-sign-error").textContent).toBe(NOT_A_BOOL);
expect(node("btn-approve-sign").disabled).toBe(true);
});
test("a bool left out shows the error, with Sign disabled", async () => {
await openSignScreen(withoutAllowed);
expect(node("approve-sign-error").textContent).toBe(MISSING);
expect(node("btn-approve-sign").disabled).toBe(true);
});
test("Sign clicked anyway decrypts and signs nothing", async () => {
const sent = await openSignScreen(daiPermit("false"));
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(NOT_A_BOOL);
});
test("a well-formed message leaves Sign enabled", async () => {
for (const allowed of [true, false]) {
await openSignScreen(daiPermit(allowed));
expect(node("approve-sign-error").textContent).toBe("");
expect(node("btn-approve-sign").disabled).toBe(false);
}
});
test("a field left out inside a list item is named by its place", () => {
const [first, second] = PERMIT_BATCH.message.details;
const withoutNonce = { ...second };
delete withoutNonce.nonce;
const data = {
...PERMIT_BATCH,
message: {
...PERMIT_BATCH.message,
details: [first, withoutNonce],
},
};
expect(typedDataRefusal(request(data))).toBe(
'The field "details[1].nonce" is missing from the message, so this typed data cannot be signed.',
);
});
// A plain lookup of a field named after a built-in object property reads
// back a function, which ethers would sign as true.
test("a field named after a built-in object property is missing when left out", () => {
const data = {
...LOGIN,
types: {
...LOGIN.types,
Login: [
...LOGIN.types.Login,
{ name: "constructor", type: "bool" },
],
},
};
expect(typedDataRefusal(request(data))).toBe(
'The field "constructor" is missing from the message, so this typed data cannot be signed.',
);
});
});
+723 -3
View File
@@ -107,6 +107,7 @@ function encodeV4Swap(actions, params) {
// V4 inner action IDs, as src/shared/uniswap.js names them.
const V4_SWAP_EXACT_IN = 0x07;
const V4_SWAP_EXACT_IN_SINGLE_ID = 0x06;
const V4_SWAP_EXACT_OUT_SINGLE_ID = 0x08;
const V4_SETTLE_ID = 0x0b;
const V4_TAKE_ID = 0x0e;
const ZERO_ADDR = "0x0000000000000000000000000000000000000000";
@@ -163,6 +164,151 @@ function encodeV4ExactInSingle(currency0, currency1, amountIn, amountOutMin) {
);
}
// Helper: V4 ExactOutputSingleParams —
// (tuple(address,address,uint24,int24,address) poolKey, bool zeroForOne,
// uint128 amountOut, uint128 amountInMaximum, bytes hookData)
function encodeV4ExactOutSingle(
currency0,
currency1,
amountOut,
amountInMaximum,
) {
return coder.encode(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
[
[
[currency0, currency1, 100, 1, ZERO_ADDR],
true, // zeroForOne: in = currency0, out = currency1
amountOut,
amountInMaximum,
"0x",
],
],
);
}
// Each V4 swap action's parameters in two shapes: the struct V4Router has
// deployed on mainnet and Sepolia, and the one v4-periphery `main` has since
// moved to, which adds a `minHopPriceX36` field. Each swaps WETH for USDC.
const POOL_KEY = "tuple(address,address,uint24,int24,address)";
const PATH_KEY = "tuple(address,uint24,int24,address,bytes)";
const WETH_USDC_POOL = [WETH_ADDR, USDC_ADDR, 100, 1, ZERO_ADDR];
const ONE_WETH = 1000000000000000000n;
const V4_SWAP_ACTION_SHAPES = [
{
action: "SWAP_EXACT_IN_SINGLE",
id: 0x06,
deployed: coder.encode(
[`tuple(${POOL_KEY},bool,uint128,uint128,bytes)`],
[[WETH_USDC_POOL, true, ONE_WETH, 990000n, "0x"]],
),
newer: coder.encode(
[`tuple(${POOL_KEY},bool,uint128,uint128,uint256,bytes)`],
[[WETH_USDC_POOL, true, ONE_WETH, 990000n, 0n, "0x"]],
),
},
{
action: "SWAP_EXACT_IN",
id: 0x07,
deployed: coder.encode(
[`tuple(address,${PATH_KEY}[],uint128,uint128)`],
[
[
WETH_ADDR,
[[USDC_ADDR, 100, 1, ZERO_ADDR, "0x"]],
ONE_WETH,
990000n,
],
],
),
newer: coder.encode(
[`tuple(address,${PATH_KEY}[],uint256[],uint128,uint128)`],
[
[
WETH_ADDR,
[[USDC_ADDR, 100, 1, ZERO_ADDR, "0x"]],
[],
ONE_WETH,
990000n,
],
],
),
},
{
action: "SWAP_EXACT_OUT_SINGLE",
id: 0x08,
deployed: coder.encode(
[`tuple(${POOL_KEY},bool,uint128,uint128,bytes)`],
[[WETH_USDC_POOL, true, 990000n, ONE_WETH, "0x"]],
),
newer: coder.encode(
[`tuple(${POOL_KEY},bool,uint128,uint128,uint256,bytes)`],
[[WETH_USDC_POOL, true, 990000n, ONE_WETH, 0n, "0x"]],
),
},
{
action: "SWAP_EXACT_OUT",
id: 0x09,
deployed: coder.encode(
[`tuple(address,${PATH_KEY}[],uint128,uint128)`],
[
[
USDC_ADDR,
[[WETH_ADDR, 100, 1, ZERO_ADDR, "0x"]],
990000n,
ONE_WETH,
],
],
),
newer: coder.encode(
[`tuple(address,${PATH_KEY}[],uint256[],uint128,uint128)`],
[
[
USDC_ADDR,
[[WETH_ADDR, 100, 1, ZERO_ADDR, "0x"]],
[],
990000n,
ONE_WETH,
],
],
),
},
];
// Helper: execute() with one V4_SWAP step holding the swap action, then
// SETTLE of WETH and TAKE of USDC.
function v4SwapCalldata(actionId, swapParams) {
return buildExecute(
"0x10",
[
encodeV4Swap(new Uint8Array([actionId, V4_SETTLE_ID, V4_TAKE_ID]), [
swapParams,
encodeV4Settle(WETH_ADDR),
encodeV4Take(USDC_ADDR),
]),
],
9999999999n,
);
}
// V4 SETTLE_ALL (currency, maxAmount) and TAKE_ALL (currency, minAmount).
const V4_SETTLE_ALL_ID = 0x0c;
const V4_TAKE_ALL_ID = 0x0f;
function encodeV4SettleAll(currency, maxAmount) {
return coder.encode(["address", "uint256"], [currency, maxAmount]);
}
function encodeV4TakeAll(currency, minAmount) {
return coder.encode(["address", "uint256"], [currency, minAmount]);
}
// V4 CLOSE_CURRENCY (currency), which the decoder does not read.
const V4_CLOSE_CURRENCY_ID = 0x12;
function encodeV4CloseCurrency(currency) {
return coder.encode(["address"], [currency]);
}
function detail(result, label) {
return result.details.find((d) => d.label === label);
}
@@ -203,9 +349,168 @@ describe("uniswap decoder", () => {
result.details.find((d) => d.label === "Min. received").value,
).toBe("0.0002 ETH");
// Real mainnet calldata is in the deployed shape, so its V4 step is
// not said to be undecoded.
const steps = result.details.find((d) => d.label === "Steps");
expect(steps.value).toContain("Permit2 Permit");
expect(steps.value).toContain("V4 Swap");
expect(steps.value).toBe("Permit2 Permit → V4 Swap");
});
test.each(V4_SWAP_ACTION_SHAPES)(
"a V4 $action in the deployed shape is read",
({ id, deployed }) => {
const result = uniswap.decode(
v4SwapCalldata(id, deployed),
ROUTER_ADDR,
);
expect(detail(result, "Steps").value).toBe("V4 Swap");
},
);
// https://git.eeqj.de/sneak/AutistMask/issues/358 — before it, all four
// newer structs decoded as the deployed ones without an error, and the
// newer SWAP_EXACT_IN showed the offset of its hop-price array as Amount
// ("0.0000000000000004 WETH") and its amountIn as Min. received.
test.each(V4_SWAP_ACTION_SHAPES)(
"a V4 $action in the newer periphery shape is shown as not decoded",
({ id, newer }) => {
const result = uniswap.decode(
v4SwapCalldata(id, newer),
ROUTER_ADDR,
);
expect(detail(result, "Amount")).toBeUndefined();
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Steps").value).toBe(
"V4 Swap (could not be decoded)",
);
// SETTLE and TAKE keep their own shape and still name both sides.
expect(detail(result, "Token In").address).toBe(WETH_ADDR);
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
},
);
// SETTLE_ALL and TAKE_ALL name both sides as SETTLE and TAKE do.
test.each(V4_SWAP_ACTION_SHAPES)(
"a V4 $action in the newer shape, then SETTLE_ALL and TAKE_ALL, takes both tokens from them",
({ id, newer }) => {
const data = buildExecute(
"0x10",
[
encodeV4Swap(
new Uint8Array([id, V4_SETTLE_ALL_ID, V4_TAKE_ALL_ID]),
[
newer,
encodeV4SettleAll(WETH_ADDR, ONE_WETH),
encodeV4TakeAll(USDC_ADDR, 990000n),
],
),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Steps").value).toBe(
"V4 Swap (could not be decoded)",
);
expect(detail(result, "Token In").address).toBe(WETH_ADDR);
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
expect(detail(result, "Amount")).toBeUndefined();
expect(detail(result, "Min. received")).toBeUndefined();
},
);
// Only the unread swap action names the currencies here. The token lines
// must not say the calldata did not name them.
test.each(V4_SWAP_ACTION_SHAPES)(
"a V4 step of only a $action in the newer shape says both tokens could not be decoded",
({ id, newer }) => {
const data = buildExecute(
"0x10",
[encodeV4Swap(new Uint8Array([id]), [newer])],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Steps").value).toBe(
"V4 Swap (could not be decoded)",
);
expect(detail(result, "Token In").value).toBe(
"Unknown (could not be decoded)",
);
expect(detail(result, "Token Out").value).toBe(
"Unknown (could not be decoded)",
);
},
);
// The user receives the V4 step's USDC, which only its unread swap
// action names. The V3 step's WETH and its Min. received figure were
// shown instead.
test.each(V4_SWAP_ACTION_SHAPES)(
"a V3 step, then a V4 step of only a $action in the newer shape, does not show the V3 step's output",
({ id, newer }) => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x10]),
[
encodeV3SwapExactIn(USER_ADDR, 2000000n, ONE_WETH, [
USDT_ADDR,
WETH_ADDR,
]),
encodeV4Swap(new Uint8Array([id]), [newer]),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Amount").value).toBe("2.0000 USDT");
expect(detail(result, "Token Out").value).toBe(
"Unknown (could not be decoded)",
);
expect(detail(result, "Min. received")).toBeUndefined();
},
);
// The user pays the V4 step's WETH, which only its unread swap action
// names. The V3 step's USDC and its amount were shown instead.
test.each(V4_SWAP_ACTION_SHAPES)(
"a V4 step of only a $action in the newer shape, then a V3 step, does not show the V3 step's input",
({ id, newer }) => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x10, 0x00]),
[
encodeV4Swap(new Uint8Array([id]), [newer]),
encodeV3SwapExactIn(
USER_ADDR,
990000n,
980000000000000000n,
[USDC_ADDR, DAI_ADDR],
),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Token In").value).toBe(
"Unknown (could not be decoded)",
);
expect(detail(result, "Amount")).toBeUndefined();
expect(detail(result, "Min. received").value).toBe("0.9800 DAI");
},
);
// UNWRAP_WETH makes Token Out ETH when no step set the output side. An
// unread swap action's step did set it, to undetermined.
test("an UNWRAP_WETH after an unread V4 swap action does not make Token Out ETH", () => {
const { id, newer } = V4_SWAP_ACTION_SHAPES[0];
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x10, 0x0c]),
[
encodeV4Swap(new Uint8Array([id]), [newer]),
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH, same encoding
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Token Out").value).toBe(
"Unknown (could not be decoded)",
);
});
test("decodes V2_SWAP_EXACT_IN with known tokens", () => {
@@ -548,9 +853,11 @@ describe("uniswap decoder", () => {
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
// The V4 swap action's parameters are empty, so that step is said to
// be undecoded.
const steps = result.details.find((d) => d.label === "Steps");
expect(steps.value).toBe(
"Permit2 Permit \u2192 V4 Swap \u2192 Unwrap WETH",
"Permit2 Permit \u2192 V4 Swap (could not be decoded) \u2192 Unwrap WETH",
);
});
@@ -858,6 +1165,419 @@ describe("uniswap decoder", () => {
expect(detail(result, "Min. received").value).toBe("0.9900 USDC");
});
// https://git.eeqj.de/sneak/AutistMask/issues/563 — a V4 step of several
// swap actions. The screen showed the first action's output as Token Out.
// Every step below pays USDT and receives USDC, so it reads Swap USDT →
// USDC whichever kind of swap actions it holds and in whatever order they
// are written. Each is tried with three endings: SETTLE_ALL and TAKE_ALL,
// SETTLE and TAKE, and CLOSE_CURRENCY for each currency, which leaves the
// sides to the swap actions alone.
const V4_STEP_ENDINGS = [
{
ending: "SETTLE_ALL and TAKE_ALL",
ids: [V4_SETTLE_ALL_ID, V4_TAKE_ALL_ID],
params: [
encodeV4SettleAll(USDT_ADDR, 1000000000n),
encodeV4TakeAll(USDC_ADDR, 990000000n),
],
},
{
ending: "SETTLE and TAKE",
ids: [V4_SETTLE_ID, V4_TAKE_ID],
params: [encodeV4Settle(USDT_ADDR), encodeV4Take(USDC_ADDR)],
},
{
ending: "CLOSE_CURRENCY",
ids: [
V4_CLOSE_CURRENCY_ID,
V4_CLOSE_CURRENCY_ID,
V4_CLOSE_CURRENCY_ID,
],
params: [
encodeV4CloseCurrency(USDT_ADDR),
encodeV4CloseCurrency(WETH_ADDR),
encodeV4CloseCurrency(USDC_ADDR),
],
},
];
// 1,000 USDT for at least 0.5 WETH
const USDT_FOR_WETH_IN = {
id: V4_SWAP_EXACT_IN_SINGLE_ID,
param: encodeV4ExactInSingle(
USDT_ADDR,
WETH_ADDR,
1000000000n,
500000000000000000n,
),
};
// 0.5 WETH for at least 990 USDC
const WETH_FOR_USDC_IN = {
id: V4_SWAP_EXACT_IN_SINGLE_ID,
param: encodeV4ExactInSingle(
WETH_ADDR,
USDC_ADDR,
500000000000000000n,
990000000n,
),
};
// exactly 0.5 WETH for at most 1,000 USDT
const USDT_FOR_WETH_OUT = {
id: V4_SWAP_EXACT_OUT_SINGLE_ID,
param: encodeV4ExactOutSingle(
USDT_ADDR,
WETH_ADDR,
500000000000000000n,
1000000000n,
),
};
// exactly 990 USDC for at most 0.5 WETH
const WETH_FOR_USDC_OUT = {
id: V4_SWAP_EXACT_OUT_SINGLE_ID,
param: encodeV4ExactOutSingle(
WETH_ADDR,
USDC_ADDR,
990000000n,
500000000000000000n,
),
};
// 400 USDT for at least 396 USDC
const USDT_FOR_USDC_IN = {
id: V4_SWAP_EXACT_IN_SINGLE_ID,
param: encodeV4ExactInSingle(
USDT_ADDR,
USDC_ADDR,
400000000n,
396000000n,
),
};
// 600 USDT through WETH for at least 594 USDC
const USDT_FOR_USDC_THROUGH_WETH_IN = {
id: V4_SWAP_EXACT_IN,
param: encodeV4ExactIn(
USDT_ADDR,
[WETH_ADDR, USDC_ADDR],
600000000n,
594000000n,
),
};
// the open delta of USDT through WETH for at least 594 USDC
const USDT_OPEN_DELTA_FOR_USDC_THROUGH_WETH_IN = {
id: V4_SWAP_EXACT_IN,
param: encodeV4ExactIn(
USDT_ADDR,
[WETH_ADDR, USDC_ADDR],
0n,
594000000n,
),
};
// Amount is the amountIn of the action that spends USDT, or the sum when
// several exact-in actions each state one. Min. received is the same for
// the actions that buy USDC. The decoder reads no amount from an exact-out
// action, and an open delta is not a stated amount.
const V4_STEPS = [
{
step: "USDT for WETH exact-in, then WETH for USDC exact-in",
swaps: [USDT_FOR_WETH_IN, WETH_FOR_USDC_IN],
amount: "1000.0000 USDT",
min: "990.0000 USDC",
},
{
step: "WETH for USDC exact-in, then USDT for WETH exact-in",
swaps: [WETH_FOR_USDC_IN, USDT_FOR_WETH_IN],
amount: "1000.0000 USDT",
min: "990.0000 USDC",
},
{
step: "WETH for USDC exact-out, then USDT for WETH exact-out",
swaps: [WETH_FOR_USDC_OUT, USDT_FOR_WETH_OUT],
amount: undefined,
min: undefined,
},
{
step: "USDT for WETH exact-out, then WETH for USDC exact-out",
swaps: [USDT_FOR_WETH_OUT, WETH_FOR_USDC_OUT],
amount: undefined,
min: undefined,
},
{
step: "USDT for WETH exact-in, then WETH for USDC exact-out",
swaps: [USDT_FOR_WETH_IN, WETH_FOR_USDC_OUT],
amount: "1000.0000 USDT",
min: undefined,
},
{
step: "WETH for USDC exact-out, then USDT for WETH exact-in",
swaps: [WETH_FOR_USDC_OUT, USDT_FOR_WETH_IN],
amount: "1000.0000 USDT",
min: undefined,
},
{
step: "USDT for WETH exact-out, then WETH for USDC exact-in",
swaps: [USDT_FOR_WETH_OUT, WETH_FOR_USDC_IN],
amount: undefined,
min: "990.0000 USDC",
},
{
step: "USDT for USDC, then USDT for USDC through WETH, both exact-in",
swaps: [USDT_FOR_USDC_IN, USDT_FOR_USDC_THROUGH_WETH_IN],
amount: "1000.0000 USDT",
min: "990.0000 USDC",
},
{
step: "USDT for USDC, then the open delta of USDT for USDC through WETH, both exact-in",
swaps: [USDT_FOR_USDC_IN, USDT_OPEN_DELTA_FOR_USDC_THROUGH_WETH_IN],
amount: undefined,
min: "990.0000 USDC",
},
];
test.each(
V4_STEPS.flatMap((step) =>
V4_STEP_ENDINGS.map((ending) => ({ ...step, ...ending })),
),
)(
"a V4 step of $step, then $ending, reads Swap USDT → USDC",
({ swaps, ids, params, amount, min }) => {
const data = buildExecute(
"0x10",
[
encodeV4Swap(
new Uint8Array([...swaps.map((s) => s.id), ...ids]),
[...swaps.map((s) => s.param), ...params],
),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Swap USDT → USDC");
expect(detail(result, "Token In").address).toBe(USDT_ADDR);
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
expect(detail(result, "Amount")?.value).toBe(amount);
expect(detail(result, "Min. received")?.value).toBe(min);
},
);
// A step with a swap action that could not be read is named only by its
// SETTLE_ALL, TAKE_ALL, SETTLE and TAKE, and states no figure: its readable
// USDT for WETH action names neither side. The unread action is
// V4_SWAP_ACTION_SHAPES[0].newer, WETH for USDC exact-in in the newer
// shape.
test.each([
{
...V4_STEP_ENDINGS[0],
tokenIn: `USDT (${USDT_ADDR})`,
tokenOut: `USDC (${USDC_ADDR})`,
},
{
...V4_STEP_ENDINGS[1],
tokenIn: `USDT (${USDT_ADDR})`,
tokenOut: `USDC (${USDC_ADDR})`,
},
{
...V4_STEP_ENDINGS[2],
tokenIn: "Unknown (could not be decoded)",
tokenOut: "Unknown (could not be decoded)",
},
])(
"a V4 step of a readable swap action and an unread one, then $ending, shows no figure",
({ ids, params, tokenIn, tokenOut }) => {
const data = buildExecute(
"0x10",
[
encodeV4Swap(
new Uint8Array([
USDT_FOR_WETH_IN.id,
V4_SWAP_ACTION_SHAPES[0].id,
...ids,
]),
[
USDT_FOR_WETH_IN.param,
V4_SWAP_ACTION_SHAPES[0].newer,
...params,
],
),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Steps").value).toBe(
"V4 Swap (could not be decoded)",
);
expect(detail(result, "Token In").value).toBe(tokenIn);
expect(detail(result, "Token Out").value).toBe(tokenOut);
expect(detail(result, "Amount")).toBeUndefined();
expect(detail(result, "Min. received")).toBeUndefined();
},
);
// A V4 step that spends WETH on USDC and on DAI. Its swap actions buy two
// currencies that none of them spends, and CLOSE_CURRENCY, which the
// decoder does not read, names no side, so its output side is unknown.
const WETH_FOR_USDC_AND_DAI = encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_CLOSE_CURRENCY_ID,
V4_CLOSE_CURRENCY_ID,
V4_CLOSE_CURRENCY_ID,
]),
[
// 0.25 WETH for at least 495 USDC
encodeV4ExactInSingle(
WETH_ADDR,
USDC_ADDR,
250000000000000000n,
495000000n,
),
// 0.25 WETH for at least 495 DAI
encodeV4ExactInSingle(
WETH_ADDR,
DAI_ADDR,
250000000000000000n,
495000000000000000000n,
),
encodeV4CloseCurrency(WETH_ADDR),
encodeV4CloseCurrency(USDC_ADDR),
encodeV4CloseCurrency(DAI_ADDR),
],
);
// The user ends with USDC and DAI, not the V3 step's WETH. That WETH and
// the V3 step's Min. received figure were shown instead.
test("a V3 step, then a V4 step whose swap actions buy two currencies, does not show the V3 step's output", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x10]),
[
encodeV3SwapExactIn(
USER_ADDR,
1000000000n,
500000000000000000n,
[USDT_ADDR, WETH_ADDR],
),
WETH_FOR_USDC_AND_DAI,
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Token In").address).toBe(
USDT_ADDR.toLowerCase(),
);
expect(detail(result, "Amount").value).toBe("1000.0000 USDT");
expect(detail(result, "Token Out").value).toBe(
"Unknown (not named in the calldata)",
);
expect(detail(result, "Min. received")).toBeUndefined();
});
// The input side of the same gap: the V4 step's swap actions spend USDC
// and DAI, two currencies that none of them buys, and nothing names its
// input side. The V3 step's WETH and its amount were shown instead.
test("a V4 step whose swap actions spend two currencies, then a V3 step, does not show the V3 step's input", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x10, 0x00]),
[
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_CLOSE_CURRENCY_ID,
V4_CLOSE_CURRENCY_ID,
V4_CLOSE_CURRENCY_ID,
]),
[
// 500 USDC for at least 0.25 WETH
encodeV4ExactInSingle(
USDC_ADDR,
WETH_ADDR,
500000000n,
250000000000000000n,
),
// 500 DAI for at least 0.25 WETH
encodeV4ExactInSingle(
DAI_ADDR,
WETH_ADDR,
500000000000000000000n,
250000000000000000n,
),
encodeV4CloseCurrency(USDC_ADDR),
encodeV4CloseCurrency(DAI_ADDR),
encodeV4CloseCurrency(WETH_ADDR),
],
),
encodeV3SwapExactIn(
USER_ADDR,
500000000000000000n,
990000000n,
[WETH_ADDR, USDT_ADDR],
),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Token In").value).toBe(
"Unknown (not named in the calldata)",
);
expect(detail(result, "Amount")).toBeUndefined();
expect(detail(result, "Token Out").address).toBe(
USDT_ADDR.toLowerCase(),
);
expect(detail(result, "Min. received").value).toBe("990.0000 USDT");
});
// UNWRAP_WETH makes Token Out ETH when no step set the output side. A V4
// step with a swap action did set it, to unknown here.
test("an UNWRAP_WETH after a V4 step whose swap actions buy two currencies does not make Token Out ETH", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x10, 0x0c]),
[
WETH_FOR_USDC_AND_DAI,
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH, same encoding
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Token Out").value).toBe(
"Unknown (not named in the calldata)",
);
});
// The router works through an exact-out path from its last entry back to
// its first: the WETH entry buys the USDC, then the USDT entry buys that
// WETH. The user pays the first entry's USDT, not the last entry's WETH.
test("a two-hop V4 exact-out action shows its first path entry as Token In", () => {
const data = buildExecute(
"0x10",
[
encodeV4Swap(new Uint8Array([0x09]), [
// SWAP_EXACT_OUT: exactly 990 USDC for at most 1,000 USDT
coder.encode(
[`tuple(address,${PATH_KEY}[],uint128,uint128)`],
[
[
USDC_ADDR,
[
[USDT_ADDR, 100, 1, ZERO_ADDR, "0x"],
[WETH_ADDR, 100, 1, ZERO_ADDR, "0x"],
],
990000000n,
1000000000n,
],
],
),
]),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Swap USDT → USDC");
expect(detail(result, "Token In").address).toBe(USDT_ADDR);
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
});
// https://git.eeqj.de/sneak/AutistMask/issues/415 — the router's V2
// exact-in reads an amountIn of zero as universal-router
// Constants.ALREADY_PAID: an earlier step sent the tokens to the pair, and
+5 -1
View File
@@ -97,7 +97,11 @@ describe("an execute() whose input currency never decoded", () => {
]);
test("says the input token is unknown instead of naming ETH", () => {
expect(detail(data(), "Token In").value).toBe(REFUSAL);
// The swap action that would name it could not be read, so the line
// says that rather than REFUSAL's "not named in the calldata".
expect(detail(data(), "Token In").value).toBe(
"Unknown (could not be decoded)",
);
});
test("keeps a Token In line, so the screen never omits what is paid", () => {